Update MediaStationGo branding and deployment docs
@@ -1,21 +1,21 @@
|
||||
# MediaStationGo
|
||||
|
||||
<p align="center">
|
||||
<img src="web/public/favicon.svg" width="96" height="96" alt="MediaStationGo Logo" />
|
||||
<img src="web/public/brand/mgo-emby-icon.png" width="96" height="96" alt="MediaStationGo Logo" />
|
||||
</p>
|
||||
|
||||
<h3 align="center">轻量、好看、适合 NAS 的私人媒体中心</h3>
|
||||
<h3 align="center">适合 NAS、家庭共享和多端播放的私人媒体中心</h3>
|
||||
|
||||
<p align="center">
|
||||
<strong>Docker 一键部署 · 多用户管理 · 媒体库 · 刮削 · 下载整理 · Emby 协议兼容 · 网盘播放</strong>
|
||||
<strong>Docker 一键部署 · PostgreSQL 主库 · Redis 热缓存 · OpenSearch 搜索增强 · Emby 协议兼容 · Bot 通知</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="README_EN.md">English</a> ·
|
||||
<a href="#快速开始">快速开始</a> ·
|
||||
<a href="#docker-compose-推荐部署">Docker 部署</a> ·
|
||||
<a href="#常见问题">常见问题</a> ·
|
||||
<a href="https://mgo.3jzs.com">在线演示</a>
|
||||
<a href="#三挡部署">三挡部署</a> ·
|
||||
<a href="#路径映射">路径映射</a> ·
|
||||
<a href="#旧-sqlite-迁移">旧 SQLite 迁移</a> ·
|
||||
<a href="#开发构建">开发构建</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -27,548 +27,300 @@
|
||||
|
||||
---
|
||||
|
||||
## 一句话介绍
|
||||
## 项目简介
|
||||
|
||||
MediaStationGo 是一个给个人、家庭 NAS、影音爱好者使用的媒体管理系统。
|
||||
MediaStationGo 是一个自托管媒体管理系统,面向 NAS、小主机、家庭影音和多用户共享场景。它把媒体库、刮削、下载整理、订阅、网盘播放、Emby 协议兼容、用户权限和 Bot 通知放在一个后台里,目标是让用户只维护一套服务,就能给网页端、手机端、电视端和第三方播放器使用。
|
||||
|
||||
你可以用它做这些事:
|
||||
核心能力:
|
||||
|
||||
- 把电影、电视剧、动漫、综艺、音乐整理成漂亮的媒体库。
|
||||
- 创建多个用户账号,给家人、朋友或不同设备分别管理登录和权限。
|
||||
- 自动识别文件、补全海报、简介、年份、季集信息。
|
||||
- 在网页里播放,也可以直接用 MediaStationGo 账号登录 Infuse、VidHub、SenPlayer、Emby 客户端等支持 Emby 协议的第三方播放器。
|
||||
- 连接 qBittorrent,做搜索、订阅、下载、整理入库。
|
||||
- 接入 OpenList / CloudDrive2 / WebDAV 等外部存储,支持 STRMURL 与 302 反代播放。
|
||||
- 在 NAS、小主机、VPS、Windows Docker Desktop 上用 Docker Compose 快速运行。
|
||||
|
||||
> 项目还在快速迭代。默认 PostgreSQL 部署请同时备份 `data/` 和 `postgres/`。
|
||||
|
||||
---
|
||||
|
||||
## 核心特点
|
||||
|
||||
- **一个服务端,多端播放**:只部署一次 MediaStationGo,不需要再重复部署 Emby 服务端。
|
||||
- **兼容 Emby 协议客户端**:第三方播放器按 Emby/Jellyfin 方式添加服务器,直接用 MediaStationGo 账号密码登录。
|
||||
- **多用户管理**:支持管理员、普通用户、账号启停、有效期、设备管理、Bot 注册/兑换码等家庭共享场景。
|
||||
- **本地媒体 + 网盘媒体统一管理**:本地硬盘、下载目录、OpenList、CloudDrive2、WebDAV 等资源可以放在同一个后台管理。
|
||||
- **下载到入库一条龙**:连接 qBittorrent 后,可做搜索、订阅、下载完成整理、刮削入库。
|
||||
- **NAS 友好**:Docker Compose 部署简单,主数据库在 `postgres/`,运行密钥和配置在 `data/`,适合低功耗 NAS 和小主机长期运行。
|
||||
|
||||
---
|
||||
|
||||
## 适合谁
|
||||
|
||||
- **新手用户**:只想复制一份 `docker-compose.yml`,改几个路径就跑起来。
|
||||
- **NAS 用户**:想用低资源占用的媒体中心管理本地硬盘和网盘资源。
|
||||
- **PT / 下载用户**:想把下载、整理、刮削、播放放到一个后台。
|
||||
- **外部播放器用户**:想用一个 MediaStationGo 账号登录支持 Emby 协议的第三方播放器 APP。
|
||||
- **家庭共享用户**:想给不同用户分配账号,不想为每个人重复搭一套媒体服务。
|
||||
- **开发者**:想研究 Go + React 的自托管媒体项目。
|
||||
|
||||
---
|
||||
|
||||
## 在线演示
|
||||
|
||||
- 地址:[https://mgo.3jzs.com](https://mgo.3jzs.com)
|
||||
- 账号:`admin`
|
||||
- 密码:`admin123`
|
||||
|
||||
> 演示站只用于看功能,请不要填写私人 API Key、站点 Cookie 或真实隐私信息。
|
||||
|
||||
---
|
||||
- **媒体库管理**:电影、电视剧、动漫、综艺、音乐和自定义媒体库统一管理。
|
||||
- **Emby 协议兼容**:Infuse、VidHub、SenPlayer、Fileball 等客户端可按 Emby/Jellyfin 方式添加服务器。
|
||||
- **本地 + 网盘**:支持本地硬盘、下载目录、OpenList、CloudDrive2、WebDAV、STRMURL 和 302 反代播放。
|
||||
- **订阅下载入库**:连接 qBittorrent 后支持搜索、订阅、下载完成整理、刮削和入库通知。
|
||||
- **多用户与权限**:管理员/普通用户、有效期、成人内容开关、设备管理、注册码和 Telegram Bot 绑定。
|
||||
- **三挡部署**:按规模选择 PostgreSQL、Redis、OpenSearch,低配 NAS 到大库检索都能覆盖。
|
||||
|
||||
## 快速开始
|
||||
|
||||
最推荐新手使用 Docker Compose。不要一开始就折腾 `.env`、裸机运行、源码编译。
|
||||
最推荐使用 Docker Compose。默认模板不依赖 `.env`,复制后按自己的 NAS 路径改 `volumes` 和路径环境变量即可。
|
||||
|
||||
```bash
|
||||
mkdir -p MediaStationGo
|
||||
cd MediaStationGo
|
||||
curl -fsSL https://raw.githubusercontent.com/ShukeBta/MediaStationGo/main/docker-compose.yml -o docker-compose.yml
|
||||
```
|
||||
|
||||
编辑 `docker-compose.yml`:
|
||||
|
||||
```bash
|
||||
vi docker-compose.yml
|
||||
```
|
||||
|
||||
然后启动:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
浏览器打开:
|
||||
启动后访问:
|
||||
|
||||
```text
|
||||
http://服务器IP:18080
|
||||
```
|
||||
|
||||
默认登录:
|
||||
默认账号:
|
||||
|
||||
```text
|
||||
账号:admin
|
||||
密码:admin123
|
||||
admin / admin123
|
||||
```
|
||||
|
||||
---
|
||||
首次登录后请立刻修改管理员密码。
|
||||
|
||||
## Docker Compose 推荐部署
|
||||
镜像地址:
|
||||
|
||||
仓库里的 `docker-compose.yml` 已经是轻量推荐模板:默认不用 `.env`,默认只启动 `MediaStationGo + PostgreSQL`,适合大多数 NAS。
|
||||
|
||||
旧版本如果已经有 `./data/mediastation.db`,首次使用新版 compose 启动时会自动导入到 PostgreSQL;`./data` 仍然要保留,用来保存 JWT 密钥、旧库迁移源和运行数据。
|
||||
|
||||
### 三种部署模式
|
||||
|
||||
| 模式 | 命令 | 适合场景 |
|
||||
| --- | --- | --- |
|
||||
| 轻量模式:PG only | `docker compose up -d` | 大多数 NAS,资源占用最低 |
|
||||
| 标准模式:PG + Redis | `docker compose -f docker-compose.yml -f docker-compose.standard.yml up -d` | 多用户、Emby 客户端频繁刷新 |
|
||||
| 搜索增强:PG + Redis + OpenSearch | `docker compose -f docker-compose.yml -f docker-compose.standard.yml -f docker-compose.search.yml up -d` | 超大库、后续独立搜索索引 |
|
||||
|
||||
建议从轻量模式开始。Redis 和 OpenSearch 都是增强层,不是源数据库;低配 NAS 不要默认开启 OpenSearch。
|
||||
|
||||
### 数据库选择与不再使用 SQLite
|
||||
|
||||
新版 Docker Compose 默认使用 PostgreSQL,不再把 SQLite 作为主数据库。判断运行时主库只看这两个配置:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
MEDIASTATION_DATABASE_TYPE: postgres
|
||||
MEDIASTATION_DATABASE_DSN: postgres://mediastation:mediastation@postgres:5432/mediastation?sslmode=disable
|
||||
```text
|
||||
GHCR:ghcr.io/shukebta/mediastation-go:latest
|
||||
Docker Hub 备用:shukbet/mediastationgo:latest
|
||||
```
|
||||
|
||||
`MEDIASTATION_DATABASE_DB_PATH` 只用于旧 SQLite 数据库的一次性导入:
|
||||
## 三挡部署
|
||||
|
||||
- 新部署:直接 `docker compose up -d`,会使用 PostgreSQL,不会创建新的 SQLite 主库。
|
||||
- 旧版本升级:如果存在 `./data/mediastation.db`,首次启动新版 compose 时会自动导入到 PostgreSQL。
|
||||
- 导入按主键补齐缺失数据,已有行会跳过;如果中途失败,修复后再次启动会继续补剩余表。
|
||||
- 成功导入后会在 PostgreSQL 的 `settings` 表写入完成标记,之后即使旧 SQLite 文件还在也不会重复导入。
|
||||
- Redis 是热缓存,OpenSearch 是搜索索引;它们都不是源数据库,丢失后可以重建。
|
||||
MediaStationGo 推荐按机器资源和用户规模选择部署档位。三挡都使用 PostgreSQL 作为主数据库;Redis 和 OpenSearch 是增强组件,不替代 PostgreSQL。
|
||||
|
||||
旧 SQLite 升级到 PostgreSQL 的建议步骤:
|
||||
| 档位 | 组件 | 适合场景 | 启动命令 |
|
||||
| --- | --- | --- | --- |
|
||||
| 第一档 | MediaStationGo + PostgreSQL | 大多数 NAS、个人/家庭使用、低内存机器 | `docker compose up -d` |
|
||||
| 第二档 | MediaStationGo + PostgreSQL + Redis | 多用户、Emby 客户端频繁刷新、首页/媒体列表访问较多 | `docker compose -f docker-compose.yml -f docker-compose.standard.yml up -d` |
|
||||
| 第三档 | MediaStationGo + PostgreSQL + Redis + OpenSearch | 超大媒体库、复杂全文搜索、后续需要独立搜索索引 | `docker compose -f docker-compose.yml -f docker-compose.standard.yml -f docker-compose.search.yml up -d` |
|
||||
|
||||
### 第一档:PostgreSQL
|
||||
|
||||
第一档是默认推荐部署。它只启动主服务和 PostgreSQL,资源占用最低,适合绝大多数 NAS。
|
||||
|
||||
```bash
|
||||
# 拉取最新镜像
|
||||
docker compose pull
|
||||
|
||||
# 启动第一档:MediaStationGo + PostgreSQL
|
||||
docker compose up -d
|
||||
docker compose logs -f mediastation-go
|
||||
```
|
||||
|
||||
看到 `sqlite data migrated to postgres`,或确认网页里的用户、媒体库、设置都正常后,再处理旧 SQLite 文件。
|
||||
|
||||
如果你确认以后不再使用 SQLite,也不希望应用再把旧 SQLite 当迁移源,可以这样做:
|
||||
|
||||
> 只有在网页确认用户、媒体库、设置、媒体条目都已经出现在 PostgreSQL 后,才做下面这一步。
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
MEDIASTATION_DATABASE_TYPE: postgres
|
||||
MEDIASTATION_DATABASE_DSN: postgres://mediastation:mediastation@postgres:5432/mediastation?sslmode=disable
|
||||
MEDIASTATION_DATABASE_DB_PATH: /data/disabled-sqlite-migration.db
|
||||
```
|
||||
|
||||
然后把宿主机上的旧文件改名或移走作为离线备份:
|
||||
|
||||
```bash
|
||||
mv data/mediastation.db data/mediastation.sqlite.bak
|
||||
```
|
||||
|
||||
裸机或自定义 `config.yaml` 部署时同理:
|
||||
|
||||
```yaml
|
||||
database:
|
||||
type: postgres
|
||||
dsn: postgres://mediastation:mediastation@127.0.0.1:5432/mediastation?sslmode=disable
|
||||
db_path: ""
|
||||
```
|
||||
|
||||
注意:不要删除 `./postgres`。迁移完成后真正的主数据库在 `./postgres`,`./data` 仍要保留,因为里面有 JWT 密钥和运行配置。
|
||||
|
||||
### 镜像地址怎么选
|
||||
|
||||
两种镜像地址都可以用,选择其中一种写到 `image:` 即可:
|
||||
|
||||
| 来源 | 镜像地址 | 适合场景 |
|
||||
| --- | --- | --- |
|
||||
| GitHub 仓库镜像 GHCR | `ghcr.io/shukebta/mediastation-go:latest` | 默认推荐,跟随仓库发布 |
|
||||
| Docker Hub | `shukbet/mediastationgo:latest` | 备用镜像,GHCR 拉取慢或不可用时使用 |
|
||||
|
||||
如果想固定版本,请先到仓库 Packages 页面确认 GHCR 是否有对应标签。写法如下:
|
||||
|
||||
```yaml
|
||||
image: ghcr.io/shukebta/mediastation-go:<版本标签>
|
||||
# GHCR 没有对应标签时,可以用 Docker Hub 备用:
|
||||
# image: shukbet/mediastationgo:MediaStationGo-v0.0.72
|
||||
```
|
||||
|
||||
如果只想简单部署,直接使用 GHCR 的 `latest` 即可。
|
||||
|
||||
手动拉取示例:
|
||||
|
||||
```bash
|
||||
# GitHub 仓库镜像
|
||||
docker pull ghcr.io/shukebta/mediastation-go:latest
|
||||
|
||||
# Docker Hub 备用
|
||||
docker pull shukbet/mediastationgo:latest
|
||||
```
|
||||
|
||||
你只需要重点看 `volumes` 这一段:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- ./data:/data
|
||||
- ./cache:/cache
|
||||
- ./media:/media
|
||||
- ./downloads:/downloads
|
||||
```
|
||||
|
||||
含义很简单:
|
||||
|
||||
| 左边 | 右边 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `./data` | 主程序 `/data` | 程序配置、JWT 密钥、旧 SQLite 迁移源;主数据库在 `./postgres` |
|
||||
| `./cache` | 主程序 `/cache` | 缓存目录;可清理 |
|
||||
| `./media` | `/media` | 媒体库目录;自动整理入库需要可写,网页里添加媒体库时填 `/media/...` |
|
||||
| `./downloads` | `/downloads` | 下载目录;文件管理和自动整理会用 |
|
||||
| `./postgres` | PostgreSQL `/var/lib/postgresql/data` | 新版默认主数据库;一定要备份 |
|
||||
| `./redis` | Redis `/data` | 标准模式才会使用;热缓存,丢失可重建 |
|
||||
| `./opensearch` | OpenSearch `/usr/share/opensearch/data` | 搜索增强模式才会使用;占用内存较高 |
|
||||
|
||||
如果你的媒体在 NAS 真实目录,例如:
|
||||
关键数据目录:
|
||||
|
||||
```text
|
||||
/vol1/1000/Media
|
||||
/vol1/1000/Downloads
|
||||
./postgres PostgreSQL 主数据库,必须备份
|
||||
./data JWT 密钥、运行配置、旧 SQLite 迁移源
|
||||
./cache 海报、临时文件、转码缓存,可删除重建
|
||||
```
|
||||
|
||||
就把 compose 改成:
|
||||
### 第二档:PostgreSQL + Redis
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- ./data:/data
|
||||
- ./cache:/cache
|
||||
- /vol1/1000/Media:/media
|
||||
- /vol1/1000/Downloads:/downloads
|
||||
第二档在第一档基础上叠加 Redis。Redis 用作热缓存,能减轻多用户和 Emby 客户端频繁刷新时的数据库压力。
|
||||
|
||||
environment:
|
||||
MEDIASTATION_MEDIA_DIR: /vol1/1000/Media
|
||||
MEDIASTATION_DOWNLOAD_DIR: /vol1/1000/Downloads
|
||||
```bash
|
||||
# 启动第二档:基础 compose + Redis 叠加文件
|
||||
docker compose -f docker-compose.yml -f docker-compose.standard.yml up -d
|
||||
```
|
||||
|
||||
注意:
|
||||
Redis 数据目录是 `./redis`。它主要保存缓存,通常可重建;真正需要备份的仍然是 `./postgres` 和 `./data`。
|
||||
|
||||
- `volumes` 左边是宿主机 / NAS 的真实路径。
|
||||
- `volumes` 右边是容器里的路径,建议固定用 `/media` 和 `/downloads`。
|
||||
- 在网页里新建媒体库时,填容器路径,例如 `/media/电影`、`/media/电视剧`。
|
||||
- 不要把 NAS 绝对路径写成 `./vol1/...`,`./` 表示当前部署目录下面的相对路径。
|
||||
- Windows Docker Desktop 可以写成 `D:/Media:/media`、`D:/Downloads:/downloads`。
|
||||
- 如果你只想扫描/播放、不使用自动整理入库,可以手动加 `:ro` 变成只读;只要要整理、重命名、入库,媒体库挂载必须保持读写。
|
||||
### 第三档:PostgreSQL + Redis + OpenSearch
|
||||
|
||||
### 最简单 compose 示例
|
||||
第三档在第二档基础上叠加 OpenSearch,用于大库全文搜索和独立搜索索引。OpenSearch 常驻内存明显更高,低配 NAS 不建议开启。
|
||||
|
||||
仓库根目录的 `docker-compose.yml` 就是这个思路。你也可以手动创建:
|
||||
```bash
|
||||
# 启动第三档:基础 compose + Redis + OpenSearch
|
||||
docker compose -f docker-compose.yml -f docker-compose.standard.yml -f docker-compose.search.yml up -d
|
||||
```
|
||||
|
||||
OpenSearch 数据目录是 `./opensearch`。搜索索引可重建,但重建大库索引会花时间;机器资源足够时再开启第三档。
|
||||
|
||||
## 配置示例
|
||||
|
||||
仓库内提供三份推荐 Compose 文件:
|
||||
|
||||
```text
|
||||
docker-compose.yml 第一档:MediaStationGo + PostgreSQL
|
||||
docker-compose.standard.yml 第二档叠加:Redis 热缓存
|
||||
docker-compose.search.yml 第三档叠加:OpenSearch 搜索增强
|
||||
```
|
||||
|
||||
常用配置片段如下,注释保留为中文,方便直接复制到 NAS 上调整:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
mediastation-go:
|
||||
# 镜像二选一:
|
||||
# GitHub 仓库镜像 GHCR:
|
||||
image: ghcr.io/shukebta/mediastation-go:latest
|
||||
# Docker Hub 备用:
|
||||
# image: shukbet/mediastationgo:latest
|
||||
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
# 访问端口:浏览器打开 http://服务器IP:18080
|
||||
ports:
|
||||
# 左边是宿主机访问端口,右边是容器内端口。
|
||||
- "18080:8080"
|
||||
|
||||
# 让容器可以访问宿主机上的 qBittorrent:
|
||||
# qB 地址可填 http://host.docker.internal:8085
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
||||
volumes:
|
||||
# 程序数据,升级前备份这个目录。
|
||||
# 运行数据:JWT 密钥、配置、旧 SQLite 迁移源。
|
||||
- ./data:/data
|
||||
|
||||
# 缓存目录:海报、临时文件、转码缓存,可删除重建。
|
||||
- ./cache:/cache
|
||||
|
||||
# 新手先用当前目录下的 media/downloads。
|
||||
# NAS 用户把左边改成真实绝对路径。
|
||||
- ./media:/media
|
||||
- ./downloads:/downloads
|
||||
# 媒体库目录:自动整理/重命名/入库需要写权限。
|
||||
- /vol1/1000/Media:/media
|
||||
|
||||
# 下载目录:qBittorrent 保存目录和自动整理源目录。
|
||||
- /vol1/1000/Downloads:/downloads
|
||||
environment:
|
||||
TZ: Asia/Shanghai
|
||||
PUID: "1000"
|
||||
PGID: "1000"
|
||||
|
||||
MEDIASTATION_APP_HOST: 0.0.0.0
|
||||
MEDIASTATION_APP_PORT: 8080
|
||||
MEDIASTATION_APP_WEB_DIR: /app/web/dist
|
||||
MEDIASTATION_APP_DATA_DIR: /data
|
||||
|
||||
# 轻量模式默认 PostgreSQL;旧 SQLite 会从这个路径自动迁移。
|
||||
# PostgreSQL 主数据库。
|
||||
MEDIASTATION_DATABASE_TYPE: postgres
|
||||
MEDIASTATION_DATABASE_DSN: postgres://mediastation:mediastation@postgres:5432/mediastation?sslmode=disable
|
||||
# 确认迁移完成后,如需彻底禁用 SQLite 迁移源,可改成 /data/disabled-sqlite-migration.db。
|
||||
|
||||
# 旧 SQLite 迁移源:只在从旧版 data/mediastation.db 导入时使用。
|
||||
MEDIASTATION_DATABASE_DB_PATH: /data/mediastation.db
|
||||
MEDIASTATION_CACHE_CACHE_DIR: /cache
|
||||
|
||||
# 如果上面的 ./media / ./downloads 改成 NAS 真实路径,
|
||||
# 这里也改成同样的宿主机真实路径。
|
||||
MEDIASTATION_MEDIA_DIR: ./media
|
||||
# 路径换算:宿主机路径和容器路径必须一一对应。
|
||||
MEDIASTATION_MEDIA_DIR: /vol1/1000/Media
|
||||
MEDIASTATION_MEDIA_CONTAINER_DIR: /media
|
||||
MEDIASTATION_DOWNLOAD_DIR: ./downloads
|
||||
MEDIASTATION_DOWNLOAD_DIR: /vol1/1000/Downloads
|
||||
MEDIASTATION_DOWNLOAD_CONTAINER_DIR: /downloads
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: mediastation
|
||||
POSTGRES_USER: mediastation
|
||||
POSTGRES_PASSWORD: mediastation
|
||||
volumes:
|
||||
- ./postgres:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U mediastation -d mediastation"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
```
|
||||
|
||||
> 说明:PostgreSQL 是主数据库;轻量模式也有进程内短缓存。Redis 是跨进程热缓存,OpenSearch 是搜索增强层,都不是源数据库。
|
||||
## 路径映射
|
||||
|
||||
---
|
||||
路径映射是 Docker 部署里最容易填错的地方。原则是:`volumes` 左边是宿主机真实路径,右边是容器内路径;环境变量里也要保持对应关系。
|
||||
|
||||
## 首次进入后怎么配置
|
||||
NAS 示例:
|
||||
|
||||
1. **新建媒体库**
|
||||
- 进入「媒体库」页面。
|
||||
- 路径填容器路径,例如 `/media/电影`。
|
||||
- 点扫描。
|
||||
```yaml
|
||||
volumes:
|
||||
- /vol1/1000/Docker/moviepilot-v2/media:/vol1/1000/Docker/moviepilot-v2/media
|
||||
- /vol1/1000/qBittorrent/downloads:/vol1/1000/qBittorrent/downloads
|
||||
environment:
|
||||
MEDIASTATION_MEDIA_DIR: /vol1/1000/Docker/moviepilot-v2/media
|
||||
MEDIASTATION_MEDIA_CONTAINER_DIR: /vol1/1000/Docker/moviepilot-v2/media
|
||||
MEDIASTATION_DOWNLOAD_DIR: /vol1/1000/qBittorrent/downloads
|
||||
MEDIASTATION_DOWNLOAD_CONTAINER_DIR: /vol1/1000/qBittorrent/downloads
|
||||
```
|
||||
|
||||
2. **配置下载器**
|
||||
- 进入「下载器管理」。
|
||||
- 如果 qBittorrent 在宿主机上,地址通常填 `http://host.docker.internal:8085`。
|
||||
Windows Docker Desktop 示例:
|
||||
|
||||
3. **配置刮削源**
|
||||
- 进入「系统设置 / 外部 API」。
|
||||
- 按需填写 TMDb、Bangumi、TheTVDB、Fanart、豆瓣等配置。
|
||||
```yaml
|
||||
volumes:
|
||||
- D:/Media:/media
|
||||
- D:/Downloads:/downloads
|
||||
environment:
|
||||
MEDIASTATION_MEDIA_DIR: D:/Media
|
||||
MEDIASTATION_MEDIA_CONTAINER_DIR: /media
|
||||
MEDIASTATION_DOWNLOAD_DIR: D:/Downloads
|
||||
MEDIASTATION_DOWNLOAD_CONTAINER_DIR: /downloads
|
||||
```
|
||||
|
||||
4. **配置外部播放器**
|
||||
- 第三方客户端按 Emby/Jellyfin 方式添加服务器。
|
||||
- 地址填 `http://服务器IP:18080`。
|
||||
- 用户名和密码填 MediaStationGo 后台创建的账号,不需要单独部署 Emby 服务端。
|
||||
- 管理员可以在后台/Bot 创建普通用户,让不同用户用自己的账号登录第三方播放器。
|
||||
如果后台添加媒体库时填的是 `/vol1/...`,Compose 里也建议把同一个 `/vol1/...` 挂进容器,避免自动整理和下载入库时路径不可访问。
|
||||
|
||||
5. **配置网盘播放**
|
||||
- 进入「外部存储」配置 OpenList、CloudDrive2、WebDAV 等。
|
||||
- 后台播放策略可以选择 STRMURL 或 302 反代。
|
||||
- 开启哪个,就优先走哪个;都关闭时走普通服务端播放链路。
|
||||
## 旧 SQLite 迁移
|
||||
|
||||
---
|
||||
新版推荐 PostgreSQL 作为主数据库。`MEDIASTATION_DATABASE_DB_PATH` 不是主库路径,而是旧 SQLite 数据的迁移源。
|
||||
|
||||
## 更新、备份、日志
|
||||
迁移步骤:
|
||||
|
||||
### 更新
|
||||
1. 把旧版 `mediastation.db` 放到 `./data/mediastation.db`。
|
||||
2. 保持 `MEDIASTATION_DATABASE_DB_PATH: /data/mediastation.db`。
|
||||
3. 启动一次,确认日志显示迁移完成,网页数据正常。
|
||||
4. 备份 `./postgres` 和 `./data`。
|
||||
5. 确认不再需要 SQLite 后,把迁移源改成不存在的路径,例如:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
# 已完成 SQLite 迁移后,建议改成不存在的路径,避免下次启动重复检查旧库。
|
||||
MEDIASTATION_DATABASE_DB_PATH: /data/no-sqlite-migration.db
|
||||
```
|
||||
|
||||
不要删除 `./postgres`。PostgreSQL 已经是主数据库,删除它会丢失账号、媒体库、订阅、配置和历史数据。
|
||||
|
||||
## 更新与备份
|
||||
|
||||
更新镜像:
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### 查看日志
|
||||
第二档和第三档更新时继续带上叠加文件:
|
||||
|
||||
```bash
|
||||
docker compose logs -f mediastation-go
|
||||
# 第二档
|
||||
docker compose -f docker-compose.yml -f docker-compose.standard.yml pull
|
||||
docker compose -f docker-compose.yml -f docker-compose.standard.yml up -d
|
||||
|
||||
# 第三档
|
||||
docker compose -f docker-compose.yml -f docker-compose.standard.yml -f docker-compose.search.yml pull
|
||||
docker compose -f docker-compose.yml -f docker-compose.standard.yml -f docker-compose.search.yml up -d
|
||||
```
|
||||
|
||||
### 备份
|
||||
|
||||
默认 PostgreSQL 部署重点备份:
|
||||
必须备份:
|
||||
|
||||
```text
|
||||
data/
|
||||
postgres/
|
||||
./postgres PostgreSQL 主数据库
|
||||
./data JWT 密钥、运行配置、旧 SQLite 迁移源
|
||||
```
|
||||
|
||||
`postgres/` 是主数据库,包含用户、媒体库、设置等核心数据;`data/` 保存 JWT 密钥、运行配置和旧 SQLite 迁移源,也要保留。
|
||||
|
||||
如果启用了增强模式,还可以按需备份:
|
||||
可重建:
|
||||
|
||||
```text
|
||||
redis/ # 热缓存,可不备份
|
||||
opensearch/ # 搜索索引,可重建;超大库可备份以减少重建时间
|
||||
./cache 图片缓存、临时文件、转码缓存
|
||||
./redis Redis 热缓存
|
||||
./opensearch 搜索索引
|
||||
```
|
||||
|
||||
`cache/` 通常不用备份。如果你仍显式使用 `database.type=sqlite` 的旧部署,主库仍在 `data/mediastation.db`。
|
||||
## Bot 与通知
|
||||
|
||||
### 停止
|
||||
MediaStationGo 支持 Telegram Bot 绑定、用户菜单、群组管理菜单和事件通知。常见通知事件包括:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
- 订阅命中新资源
|
||||
- 下载任务完成
|
||||
- 入库完成
|
||||
- 刮削失败告警
|
||||
- 系统异常通知
|
||||
|
||||
---
|
||||
管理员可以在后台配置 Bot Token、Chat ID、通知频道和事件类型。群组里管理类命令只允许管理员执行,普通用户只能看到和使用用户命令。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 1. 页面打不开?
|
||||
**启动后还是反复迁移 SQLite?**
|
||||
|
||||
先看容器是否启动:
|
||||
|
||||
```bash
|
||||
docker ps
|
||||
docker compose logs --tail=100 mediastation-go
|
||||
```
|
||||
|
||||
确认浏览器访问的是:
|
||||
|
||||
```text
|
||||
http://服务器IP:18080
|
||||
```
|
||||
|
||||
### 2. 媒体库扫描不到文件?
|
||||
|
||||
最常见原因是路径写错。
|
||||
|
||||
- Docker `volumes` 右边是 `/media`。
|
||||
- 网页媒体库路径就应该填 `/media/电影`,不要填 NAS 原始路径。
|
||||
- 如果 qB 下载目录是 `/downloads`,自动整理源目录也优先填 `/downloads`。
|
||||
|
||||
### 3. qBittorrent 连不上?
|
||||
|
||||
如果 qB 在宿主机上,地址试试:
|
||||
|
||||
```text
|
||||
http://host.docker.internal:8085
|
||||
```
|
||||
|
||||
如果 qB 在另一台机器上,填那台机器的局域网 IP。
|
||||
|
||||
### 4. NAS CPU 占用高?
|
||||
|
||||
建议先在系统设置里确认:
|
||||
|
||||
- `ffprobe.max_concurrent` 设为 `1`。
|
||||
- 自动整理、扫描后刮削、启动后扫描网盘按需开启。
|
||||
- 大媒体库不要频繁全量扫描,优先手动扫描或夜间同步。
|
||||
|
||||
### 5. 要不要用 `.env`?
|
||||
|
||||
新手不建议。直接改 `docker-compose.yml` 最直观。
|
||||
|
||||
`.env` 适合进阶用户在多台机器复用同一份 compose。仓库保留 `docker-compose.simple.env.example`,但它不是推荐主线。
|
||||
|
||||
---
|
||||
|
||||
## 功能概览
|
||||
|
||||
| 分类 | 功能 |
|
||||
| --- | --- |
|
||||
| 媒体库 | 电影、电视剧、动漫、综艺、音乐、成人内容 |
|
||||
| 元数据 | NFO、本地图片、TMDb、TheTVDB、Bangumi、豆瓣、Fanart、JavBus/JavDB |
|
||||
| 播放 | Web 播放、Range 拖动、HLS 转码、直链、STRMURL、302 反代 |
|
||||
| 外部客户端 | Emby 协议兼容接口,MediaStationGo 账号可直接登录第三方播放器 |
|
||||
| 用户管理 | 多用户、管理员/普通用户、账号有效期、设备管理、Bot 注册与兑换码 |
|
||||
| 下载 | qBittorrent、站点搜索、订阅、下载完成后整理 |
|
||||
| 文件管理 | 浏览、整理、复制、移动、硬链接、软链接 |
|
||||
| 运维 | 任务队列、回收站、重复文件、通知渠道、运行日志 |
|
||||
| AI | OpenAI Compatible API、AI 搜索、推荐、助手 |
|
||||
|
||||
---
|
||||
|
||||
## 截图
|
||||
|
||||
<details open>
|
||||
<summary><strong>界面预览</strong></summary>
|
||||
|
||||
| 登录 | 首页 |
|
||||
| --- | --- |
|
||||
| <img src="docs/screenshots/00-login.jpg" alt="登录" width="100%"> | <img src="docs/screenshots/01-home.jpg" alt="首页" width="100%"> |
|
||||
|
||||
| 媒体库 | 播放器 |
|
||||
| --- | --- |
|
||||
| <img src="docs/screenshots/02-libraries.jpg" alt="媒体库" width="100%"> | <img src="docs/screenshots/06-player.jpg" alt="播放器" width="100%"> |
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 开发者运行
|
||||
|
||||
普通用户请优先使用 Docker。开发者可以这样运行:
|
||||
|
||||
```bash
|
||||
go run ./cmd/server
|
||||
```
|
||||
|
||||
前端:
|
||||
|
||||
```bash
|
||||
cd web
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
测试:
|
||||
确认旧数据已经迁移成功后,把 `MEDIASTATION_DATABASE_DB_PATH` 改成不存在的路径,例如 `/data/no-sqlite-migration.db`,然后重启容器。
|
||||
|
||||
**扫库或入库速度很慢?**
|
||||
|
||||
先确认数据库档位和路径映射正确。第一档已经足够大多数场景;第二档 Redis 能缓解频繁刷新造成的数据库压力;第三档主要增强搜索,不会替代媒体扫描本身。网盘扫描还会受网盘接口响应、目录数量和网络质量影响。
|
||||
|
||||
**qBittorrent 下载完成后无法整理?**
|
||||
|
||||
确认 qBittorrent 保存路径已经通过 `volumes` 挂载进 MediaStationGo 容器,并且 `MEDIASTATION_DOWNLOAD_DIR` 与 `MEDIASTATION_DOWNLOAD_CONTAINER_DIR` 对应正确。
|
||||
|
||||
**第三方播放器无法连接?**
|
||||
|
||||
确认播放器填写的是 `http://服务器IP:18080`,账号密码使用 MediaStationGo 用户账号。反代部署时需要正确设置外部访问地址和 HTTPS 头。
|
||||
|
||||
## 开发构建
|
||||
|
||||
本地开发需要 Go、Node.js 和 npm。
|
||||
|
||||
```bash
|
||||
# 后端测试
|
||||
go test ./...
|
||||
cd web && npm run build
|
||||
|
||||
# 前端依赖与构建
|
||||
npm --prefix web install
|
||||
npm --prefix web run build
|
||||
|
||||
# 本地运行后端
|
||||
go run ./cmd/server
|
||||
|
||||
# 本地运行前端开发服务器
|
||||
npm --prefix web run dev
|
||||
```
|
||||
|
||||
---
|
||||
前端开发服务器默认访问:
|
||||
|
||||
## 社区与友链
|
||||
```text
|
||||
http://127.0.0.1:3000
|
||||
```
|
||||
|
||||
- Telegram MediaStationGo交流群:<https://t.me/MediaStationGo>
|
||||
- NodeSeek:[https://www.nodeseek.com/](https://www.nodeseek.com/)
|
||||
- LINUX DO:[https://linux.do/](https://linux.do/)
|
||||
后端健康检查:
|
||||
|
||||
---
|
||||
```text
|
||||
http://127.0.0.1:8080/api/health
|
||||
```
|
||||
|
||||
## 赞赏
|
||||
## 许可证
|
||||
|
||||
如果这个项目节省了你的时间,欢迎请作者吃桶泡面。
|
||||
|
||||
<img width="200" height="200" alt="微信赞赏码" src="https://github.com/user-attachments/assets/d6077de5-8305-400d-8b82-470ef05d926e" />
|
||||
|
||||
---
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=ShukeBta%2FMediaStationGo&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=ShukeBta/MediaStationGo&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=ShukeBta/MediaStationGo&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=ShukeBta/MediaStationGo&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
## 许可证与非商用声明
|
||||
|
||||
本项目基础许可证遵循 `GPL-3.0`,详见 [LICENSE](LICENSE)。
|
||||
|
||||
项目维护者同时声明并倡议:
|
||||
|
||||
- 本项目主要面向个人学习、家庭 NAS、自建影音、非商业研究与社区共建场景。
|
||||
- 未经作者明确书面许可,不得将本项目或衍生版本用于商业售卖、商业托管、付费 SaaS、预装售卖设备、闭源二次分发或其他商业化牟利用途。
|
||||
- 如需商业合作、企业内部部署、定制开发、集成发行或商业授权,请先联系作者确认授权边界。
|
||||
- 若 README 的非商用声明与 `GPL-3.0` 正式许可文本存在解释差异,代码授权以 [LICENSE](LICENSE) 文件为准,商业使用请额外取得作者许可。
|
||||
|
||||
---
|
||||
|
||||
<p align="center">Made with ❤️ by ShukeBta</p>
|
||||
本项目使用 GPL-3.0 License。详见 [LICENSE](LICENSE)。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# MediaStationGo
|
||||
|
||||
<p align="center">
|
||||
<img src="web/public/favicon.svg" width="96" height="96" alt="MediaStationGo Logo" />
|
||||
<img src="web/public/brand/mgo-emby-icon.png" width="96" height="96" alt="MediaStationGo Logo" />
|
||||
</p>
|
||||
|
||||
<h3 align="center">A lightweight, polished, NAS-friendly private media center</h3>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Package main is the MediaStationGo HTTP server entry point.
|
||||
//
|
||||
// MediaStationGo is a Go rewrite of the original Python MediaStation project,
|
||||
// MediaStationGo is a Go rewrite of the legacy Python implementation,
|
||||
// adopting the same tech stack as cropflre/nowen-video:
|
||||
//
|
||||
// Backend: Go 1.25 + Gin + GORM + PostgreSQL/SQLite + Viper + Zap + JWT
|
||||
|
||||
@@ -109,7 +109,7 @@ ai:
|
||||
max_concurrent: 3
|
||||
|
||||
license:
|
||||
# Optional private MediaStationLicenseServer bridge.
|
||||
# Optional private MediaStationGo license server bridge.
|
||||
# Open-source mode works without this and is limited to 20 users.
|
||||
server_url: "" # e.g. http://127.0.0.1:8001
|
||||
hmac_secret: "" # must match LICENSE_HMAC_SECRET on the license server
|
||||
|
||||
@@ -20,7 +20,8 @@
|
||||
# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.32
|
||||
#
|
||||
# 路径映射总览:
|
||||
# /data 程序数据目录。保存 SQLite 数据库、JWT secret、系统配置等,必须持久化。
|
||||
# /data 程序运行数据目录。保存 JWT secret、系统配置、旧 SQLite 迁移源,必须持久化。
|
||||
# /postgres PostgreSQL 主数据库目录,账号、媒体库、订阅等主数据都在这里,必须备份。
|
||||
# /cache 缓存目录。保存海报、刮削图片、转码缓存等,建议放在空间较大的磁盘。
|
||||
# /media 媒体库只读挂载目录。网页中添加媒体库时填写容器内路径,例如 /media/Movies。
|
||||
# /downloads 下载目录。下载器保存路径建议填写容器内路径,例如 /downloads/Movies。
|
||||
@@ -78,7 +79,8 @@ services:
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
||||
volumes:
|
||||
# 程序持久化数据:数据库、JWT secret、运行时配置。
|
||||
# 程序运行数据:JWT secret、运行配置、旧 SQLite 迁移源。
|
||||
# 主数据库在 postgres 服务的 /var/lib/postgresql/data。
|
||||
- ${MEDIASTATION_DATA_DIR:-./data}:/data
|
||||
|
||||
# 海报/背景图/转码缓存。可删除重建,但会重新下载图片和生成缓存。
|
||||
@@ -118,6 +120,7 @@ services:
|
||||
MEDIASTATION_APP_DATA_DIR: /data
|
||||
MEDIASTATION_DATABASE_TYPE: ${MEDIASTATION_DATABASE_TYPE:-postgres}
|
||||
MEDIASTATION_DATABASE_DSN: ${MEDIASTATION_DATABASE_DSN:-postgres://mediastation:mediastation@postgres:5432/mediastation?sslmode=disable}
|
||||
# SQLite 旧库迁移源。确认迁移完成后,可改成 /data/no-sqlite-migration.db。
|
||||
MEDIASTATION_DATABASE_DB_PATH: /data/mediastation.db
|
||||
MEDIASTATION_CACHE_CACHE_DIR: /cache
|
||||
|
||||
|
||||
@@ -39,7 +39,8 @@ services:
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
||||
volumes:
|
||||
# 程序数据:数据库、账号、设置都在这里。升级前主要备份它。
|
||||
# 程序运行数据:JWT 密钥、运行配置、旧 SQLite 迁移源。
|
||||
# 主数据库在 ./postgres,升级/备份时 ./data 和 ./postgres 都要保留。
|
||||
- ./data:/data
|
||||
|
||||
# 缓存目录:海报缓存、临时文件等。通常不用备份。
|
||||
@@ -82,7 +83,9 @@ services:
|
||||
MEDIASTATION_DATABASE_TYPE: postgres
|
||||
MEDIASTATION_DATABASE_DSN: postgres://mediastation:mediastation@postgres:5432/mediastation?sslmode=disable
|
||||
|
||||
# SQLite 旧库位置:仅用于旧数据自动迁移和无 PG 回退。
|
||||
# SQLite 旧库迁移源:
|
||||
# - 首次从旧版 ./data/mediastation.db 导入时保持此路径。
|
||||
# - 确认迁移完成后,建议改成 /data/no-sqlite-migration.db 这类不存在的路径。
|
||||
MEDIASTATION_DATABASE_DB_PATH: /data/mediastation.db
|
||||
MEDIASTATION_CACHE_CACHE_DIR: /cache
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# MediaStation 原版完整功能迁移清单
|
||||
# 旧版完整功能迁移清单
|
||||
|
||||
> 基于 `MediaStation-py`(Python/FastAPI + Vue 3)源代码分析,供 MediaStationGo(Go/Gin + React)重写参考。
|
||||
> 基于旧版 Python/FastAPI + Vue 3 源代码分析,供 MediaStationGo(Go/Gin + React)重写参考。
|
||||
>
|
||||
> 分析日期:2025-07-09
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
- **部署**: Docker / Nginx 反向代理 / Helm Chart
|
||||
|
||||
### 核心定位
|
||||
MediaStation 是一个轻量级家庭媒体服务器,融合 **媒体播放 + 自动化订阅下载 + 多平台资源聚合**。
|
||||
MediaStationGo 是一个轻量级家庭媒体服务器,融合 **媒体播放 + 自动化订阅下载 + 多平台资源聚合**。
|
||||
|
||||
---
|
||||
|
||||
@@ -593,7 +593,7 @@ MediaStation 是一个轻量级家庭媒体服务器,融合 **媒体播放 +
|
||||
|
||||
> 源文件:`backend/app/emby_api.py`(~1800 行,完整的 Emby Server API v3 兼容)
|
||||
|
||||
提供 Emby API 子集,让 **Infuse、Kodi、Fileball** 等客户端可以直接连接 MediaStation。
|
||||
提供 Emby API 子集,让 **Infuse、Kodi、Fileball** 等客户端可以直接连接 MediaStationGo。
|
||||
|
||||
**核心 Emby 端点**(仅列出关键部分,实际约 50+ 端点):
|
||||
|
||||
@@ -902,7 +902,7 @@ MediaStation 是一个轻量级家庭媒体服务器,融合 **媒体播放 +
|
||||
|
||||
| 分类 | 变量 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| **应用** | APP_NAME | MediaStation | 应用名 |
|
||||
| **应用** | APP_NAME | MediaStationGo | 应用名 |
|
||||
| | APP_PORT | 3001 | 端口 |
|
||||
| | APP_DEBUG | false | 调试模式 |
|
||||
| | APP_SECRET_KEY | AUTO_GENERATE | JWT 密钥(自动生成警告) |
|
||||
@@ -995,4 +995,4 @@ MediaStation 是一个轻量级家庭媒体服务器,融合 **媒体播放 +
|
||||
|
||||
---
|
||||
|
||||
> **文档版本**: v1.0 | **分析范围**: `MediaStation-py` 全量源代码
|
||||
> **文档版本**: v1.0 | **分析范围**: 旧版 Python 全量源代码
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> **版本**: v1.0 | **日期**: 2026-02-04 | **作者**: Architect (Bob)
|
||||
>
|
||||
> 本文档基于原版 MediaStation(~220 API)与当前 Go 版实现(~50+ API)的差距分析,
|
||||
> 本文档基于旧版 Python 实现(~220 API)与当前 Go 版实现(~50+ API)的差距分析,
|
||||
> 设计完整的重构架构方案,涵盖数据模型、文件结构、依赖、任务分解和跨模块约定。
|
||||
|
||||
---
|
||||
|
||||
@@ -166,7 +166,7 @@ type AIConfig struct {
|
||||
MaxConcurrent int `mapstructure:"max_concurrent"`
|
||||
}
|
||||
|
||||
// LicenseConfig configures the optional MediaStationLicenseServer bridge.
|
||||
// LicenseConfig configures the optional MediaStationGo license server bridge.
|
||||
type LicenseConfig struct {
|
||||
ServerURL string `mapstructure:"server_url"`
|
||||
HMACSecret string `mapstructure:"hmac_secret"`
|
||||
|
||||
@@ -645,7 +645,7 @@ func embyFallbackUser(id string) gin.H {
|
||||
}
|
||||
return gin.H{
|
||||
"Id": id,
|
||||
"Name": "MediaStation",
|
||||
"Name": "MediaStationGo",
|
||||
"ServerId": "mediastation-go-001",
|
||||
"HasPassword": true,
|
||||
"HasConfiguredPassword": true,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Package handler wires the HTTP routes to the service container.
|
||||
//
|
||||
// All routes are mounted under /api/* (matching the original MediaStation
|
||||
// surface) so the frontend dev-server can proxy a single prefix.
|
||||
// All routes are mounted under /api/* so the frontend dev-server can proxy a
|
||||
// single prefix.
|
||||
package handler
|
||||
|
||||
import (
|
||||
@@ -162,7 +162,3 @@ func sseHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -124,15 +124,33 @@ func scanLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
||||
})
|
||||
return
|
||||
}
|
||||
task := startScanHTTPTask(svc, "手动扫描入库", lib.Name, lib.Path)
|
||||
res, err := svc.Scan.ScanLibrary(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
finishHTTPTask(task, err, "scan", "手动扫描入库失败", nil, nil)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
finishScan, ok := svc.Scan.TryBeginLocalScan(id)
|
||||
if !ok {
|
||||
c.JSON(http.StatusAccepted, gin.H{
|
||||
"library_id": id,
|
||||
"queued": true,
|
||||
"already_running": true,
|
||||
"message": "该媒体库正在后台扫描,请在任务面板查看进度",
|
||||
"estimate_message": "页面关闭不会中断扫描",
|
||||
})
|
||||
return
|
||||
}
|
||||
finishHTTPTask(task, nil, "completed", "手动扫描入库结束", scanTaskMetrics(res), scanTaskDetails(res, 20))
|
||||
c.JSON(http.StatusOK, res)
|
||||
task := startScanHTTPTask(svc, "手动扫描入库", lib.Name, lib.Path)
|
||||
go func(libraryID string, task *service.TaskHandle, finish func()) {
|
||||
defer finish()
|
||||
res, err := svc.Scan.ScanLibrary(context.Background(), libraryID)
|
||||
if err != nil {
|
||||
finishHTTPTask(task, err, "scan", "手动扫描入库失败", scanTaskMetrics(res), scanTaskDetails(res, 20))
|
||||
return
|
||||
}
|
||||
finishHTTPTask(task, nil, "completed", "手动扫描入库结束", scanTaskMetrics(res), scanTaskDetails(res, 20))
|
||||
}(id, task, finishScan)
|
||||
c.JSON(http.StatusAccepted, gin.H{
|
||||
"library_id": id,
|
||||
"queued": true,
|
||||
"message": "本地媒体库扫描已在后台运行,页面关闭不会中断",
|
||||
"estimate_message": "可在右上角任务面板查看扫描进度",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ func registerAuthenticatedRoutes(api *gin.RouterGroup, cfg *config.Config, svc *
|
||||
// Permissions.
|
||||
authed.GET("/auth/permissions", getMyPermissionsHandler(svc))
|
||||
|
||||
// License activation bridge (admin only; talks to MediaStationLicenseServer).
|
||||
// License activation bridge (admin only; talks to the configured license server).
|
||||
authed.GET("/license/status", middleware.AdminRequired(), licenseStatusHandler(svc))
|
||||
authed.POST("/license/activate", middleware.AdminRequired(), licenseActivateHandler(svc))
|
||||
authed.POST("/license/heartbeat", middleware.AdminRequired(), licenseHeartbeatHandler(svc))
|
||||
|
||||
@@ -16,12 +16,12 @@ const (
|
||||
RegistrationCodeRenew = "renew"
|
||||
)
|
||||
|
||||
// RegistrationCode 是一次性兑换码。管理员生成后发给用户,用户通过 Bot 兑换:
|
||||
// RegistrationCode 是兑换码。管理员生成后发给用户,用户通过 Bot 兑换:
|
||||
// - register:创建并绑定一个新账号;兑换时按 DurationDays 设置账号有效期。
|
||||
// - renew:给当前绑定账号延长 DurationDays 天有效期。
|
||||
//
|
||||
// 兑换成功后记录 UsedByUserID + UsedAt,之后不可再用。ExpiresAt 是兑换码本身
|
||||
// 的有效期(过期后即使未使用也不能再兑换)。
|
||||
// MaxUses 控制最多可兑换次数,旧数据/零值按 1 次处理。UsedAt 表示达到最大
|
||||
// 次数后的耗尽时间;ExpiresAt 是兑换码本身的有效期。
|
||||
type RegistrationCode struct {
|
||||
Base
|
||||
Code string `gorm:"uniqueIndex;size:32;not null" json:"code"`
|
||||
@@ -29,6 +29,8 @@ type RegistrationCode struct {
|
||||
DurationDays int `gorm:"default:0" json:"duration_days"` // 账号有效期天数;0 表示永久
|
||||
CreatedByID string `gorm:"size:36" json:"created_by_id,omitempty"`
|
||||
UsedByUserID string `gorm:"index;size:36" json:"used_by_user_id,omitempty"`
|
||||
MaxUses int `gorm:"default:1" json:"max_uses"`
|
||||
UsedCount int `gorm:"default:0" json:"used_count"`
|
||||
UsedAt *time.Time `json:"used_at,omitempty"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"` // 兑换码本身的有效期
|
||||
}
|
||||
@@ -41,8 +43,19 @@ func (c *RegistrationCode) BeforeCreate(_ *gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsUsed 报告兑换码是否已被使用。
|
||||
func (c *RegistrationCode) IsUsed() bool { return c.UsedAt != nil }
|
||||
// EffectiveMaxUses returns the configured max uses, treating legacy zero values
|
||||
// as one-use codes.
|
||||
func (c *RegistrationCode) EffectiveMaxUses() int {
|
||||
if c == nil || c.MaxUses <= 0 {
|
||||
return 1
|
||||
}
|
||||
return c.MaxUses
|
||||
}
|
||||
|
||||
// IsUsed 报告兑换码是否已耗尽。
|
||||
func (c *RegistrationCode) IsUsed() bool {
|
||||
return c != nil && (c.UsedAt != nil || c.UsedCount >= c.EffectiveMaxUses())
|
||||
}
|
||||
|
||||
// IsExpired 报告兑换码自身是否过期(与账号有效期无关)。
|
||||
func (c *RegistrationCode) IsExpired() bool {
|
||||
|
||||
@@ -33,14 +33,18 @@ func (r *RegistrationCodeRepository) FindByCode(ctx context.Context, code string
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// MarkUsed atomically marks an unused, unexpired code as consumed by userID.
|
||||
// It returns gorm.ErrRecordNotFound when the code was already used so callers
|
||||
// can avoid double-spend races.
|
||||
// MarkUsed atomically consumes one use of a redeemable code. It returns
|
||||
// gorm.ErrRecordNotFound when the code is exhausted so callers can avoid
|
||||
// double-spend races.
|
||||
func (r *RegistrationCodeRepository) MarkUsed(ctx context.Context, id, userID string) error {
|
||||
now := time.Now()
|
||||
res := r.db.WithContext(ctx).Model(&model.RegistrationCode{}).
|
||||
Where("id = ? AND used_at IS NULL", id).
|
||||
Updates(map[string]any{"used_by_user_id": userID, "used_at": &now})
|
||||
Where("id = ? AND used_at IS NULL AND used_count < CASE WHEN max_uses > 0 THEN max_uses ELSE 1 END", id).
|
||||
Updates(map[string]any{
|
||||
"used_by_user_id": userID,
|
||||
"used_count": gorm.Expr("used_count + 1"),
|
||||
"used_at": gorm.Expr("CASE WHEN used_count + 1 >= CASE WHEN max_uses > 0 THEN max_uses ELSE 1 END THEN ? ELSE used_at END", now),
|
||||
})
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
@@ -64,7 +68,7 @@ func (r *RegistrationCodeRepository) List(ctx context.Context, limit int) ([]mod
|
||||
func (r *RegistrationCodeRepository) CountUnused(ctx context.Context) (int64, error) {
|
||||
var n int64
|
||||
err := r.db.WithContext(ctx).Model(&model.RegistrationCode{}).
|
||||
Where("used_at IS NULL").Count(&n).Error
|
||||
Where("used_at IS NULL AND used_count < CASE WHEN max_uses > 0 THEN max_uses ELSE 1 END").Count(&n).Error
|
||||
return n, err
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
// transparently encrypts the api_key column on write and decrypts it on
|
||||
// read so values stored on disk are useless without the JWT secret.
|
||||
//
|
||||
// On first read it seeds the table with the providers MediaStation
|
||||
// supports today (TMDb / Bangumi / TheTVDB / Fanart / OpenAI / Douban).
|
||||
// On first read it seeds the table with the providers supported by
|
||||
// MediaStationGo today (TMDb / Bangumi / TheTVDB / Fanart / OpenAI / Douban).
|
||||
package service
|
||||
|
||||
import (
|
||||
|
||||
@@ -46,7 +46,7 @@ var (
|
||||
const MaxUsers = OpenSourceUserLimit
|
||||
|
||||
// SeedAdmin makes sure at least one admin user exists. It mirrors the
|
||||
// MediaStation behaviour: if no admin row is found we create
|
||||
// legacy default behaviour: if no admin row is found we create
|
||||
// `admin / admin123` (overridable through ADMIN_INITIAL_PASSWORD) and warn.
|
||||
func (s *AuthService) SeedAdmin(ctx context.Context) error {
|
||||
n, err := s.repo.User.CountAdmins(ctx)
|
||||
|
||||
@@ -113,13 +113,21 @@ func (s *TelegramBotService) consumeOpenRegSlot(ctx context.Context) {
|
||||
// sets the account validity granted on redeem (0 = permanent). validDays sets
|
||||
// how long the code itself stays redeemable (0 = never expires).
|
||||
func (s *TelegramBotService) generateCode(ctx context.Context, kind string, durationDays, validDays int, createdBy string) (*model.RegistrationCode, error) {
|
||||
return s.generateCodeWithUses(ctx, kind, durationDays, validDays, 1, createdBy)
|
||||
}
|
||||
|
||||
func (s *TelegramBotService) generateCodeWithUses(ctx context.Context, kind string, durationDays, validDays, maxUses int, createdBy string) (*model.RegistrationCode, error) {
|
||||
if kind != model.RegistrationCodeRegister && kind != model.RegistrationCodeRenew {
|
||||
kind = model.RegistrationCodeRegister
|
||||
}
|
||||
if maxUses <= 0 {
|
||||
maxUses = 1
|
||||
}
|
||||
code := &model.RegistrationCode{
|
||||
Code: randomCode(12),
|
||||
Kind: kind,
|
||||
DurationDays: durationDays,
|
||||
MaxUses: maxUses,
|
||||
CreatedByID: createdBy,
|
||||
}
|
||||
if validDays > 0 {
|
||||
|
||||
@@ -176,6 +176,40 @@ func TestRegistrationCodeRedeemOnce(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistrationCodeCanBeGeneratedForMultipleUses(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos, bot := newBotTestService(t)
|
||||
|
||||
code, err := bot.generateCodeWithUses(ctx, model.RegistrationCodeRenew, 30, 0, 2, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rc, msg := bot.lookupRedeemableCode(ctx, code.Code, model.RegistrationCodeRenew)
|
||||
if rc == nil {
|
||||
t.Fatalf("expected valid code, got msg=%q", msg)
|
||||
}
|
||||
if err := repos.RegCode.MarkUsed(ctx, rc.ID, "user-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rc, msg = bot.lookupRedeemableCode(ctx, code.Code, model.RegistrationCodeRenew)
|
||||
if rc == nil {
|
||||
t.Fatalf("code should remain redeemable after first use, got msg=%q", msg)
|
||||
}
|
||||
if err := repos.RegCode.MarkUsed(ctx, rc.ID, "user-2"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, msg := bot.lookupRedeemableCode(ctx, code.Code, model.RegistrationCodeRenew); msg == "" {
|
||||
t.Fatal("code should be exhausted after max uses")
|
||||
}
|
||||
var used model.RegistrationCode
|
||||
if err := repos.DB.Where("id = ?", code.ID).First(&used).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if used.UsedCount != 2 || used.UsedAt == nil {
|
||||
t.Fatalf("expected exhausted code with used_count=2, got %+v", used)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewalClearsExpiry(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos, bot := newBotTestService(t)
|
||||
@@ -976,6 +1010,29 @@ func TestBotAdminCodeAndUserCommands(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotGroupMenuShowsAdminActionsOnlyForAdmins(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
_, bot := newBotTestService(t)
|
||||
channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9301","group_chat_id":"-1001"}`}
|
||||
adminMsg := &TelegramMessage{From: TelegramUser{ID: 9301, Username: "admin"}, Chat: TelegramChat{ID: -1001, Type: "group"}}
|
||||
reply, err := bot.executeCommand(ctx, channel, adminMsg, "/menu")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(reply.Text, "管理员入口") || len(reply.Buttons) == 0 {
|
||||
t.Fatalf("admin group menu should expose management actions, got %#v", reply)
|
||||
}
|
||||
|
||||
userMsg := &TelegramMessage{From: TelegramUser{ID: 9302, Username: "user"}, Chat: TelegramChat{ID: -1001, Type: "group"}}
|
||||
reply, err = bot.executeCommand(ctx, channel, userMsg, "/menu")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(reply.Text, "管理员入口") {
|
||||
t.Fatalf("non-admin group menu must not expose management actions, got %#v", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotAdminUnbindMultipleUsers(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos, bot := newBotTestService(t)
|
||||
|
||||
@@ -48,6 +48,7 @@ type DownloadService struct {
|
||||
scanner *ScannerService
|
||||
site *SiteService
|
||||
tasks *TaskTrackerService
|
||||
notify *NotifyChannelService
|
||||
|
||||
mu sync.Mutex
|
||||
stopCh chan struct{}
|
||||
@@ -71,6 +72,10 @@ func (d *DownloadService) SetTaskTracker(tasks *TaskTrackerService) {
|
||||
d.tasks = tasks
|
||||
}
|
||||
|
||||
func (d *DownloadService) SetNotifyChannels(notify *NotifyChannelService) {
|
||||
d.notify = notify
|
||||
}
|
||||
|
||||
var torrentEpisodeToken = regexp.MustCompile(`(?i)e\d{1,3}`)
|
||||
|
||||
const settingDownloadClientsManaged = "download_clients.managed"
|
||||
@@ -1201,6 +1206,7 @@ func downloadTaskNeedsCompletion(task model.DownloadTask) bool {
|
||||
// Media rows is too late for freshly-downloaded files: they usually have not
|
||||
// been scanned into the library yet.
|
||||
func (d *DownloadService) onTorrentComplete(ctx context.Context, torrent QBitTorrent) {
|
||||
d.notifyDownloadComplete(torrent)
|
||||
if d.organizer == nil {
|
||||
return
|
||||
}
|
||||
@@ -1268,6 +1274,25 @@ func (d *DownloadService) onTorrentComplete(ctx context.Context, torrent QBitTor
|
||||
zap.Int("errors", len(res.Errors)))
|
||||
}
|
||||
|
||||
func (d *DownloadService) notifyDownloadComplete(torrent QBitTorrent) {
|
||||
if d == nil || d.notify == nil {
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(torrent.Name)
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(filepath.Base(torrent.ContentPath))
|
||||
}
|
||||
if name == "" {
|
||||
name = "下载任务"
|
||||
}
|
||||
body := fmt.Sprintf("任务:%s\n保存路径:%s\nHash:%s", name, firstNonEmpty(torrent.ContentPath, torrent.SavePath), torrent.Hash)
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
d.notify.Broadcast(ctx, "MediaStationGo 下载完成", body, EventDownloadComplete)
|
||||
}()
|
||||
}
|
||||
|
||||
func (d *DownloadService) downloadOrganizeTaskName(torrent QBitTorrent, allowReplace bool) string {
|
||||
name := strings.TrimSpace(torrent.Name)
|
||||
if name == "" {
|
||||
|
||||
@@ -490,10 +490,10 @@ func (e *EmbyService) mediaItems(ctx context.Context, p ItemsParams) (map[string
|
||||
return emptyItemsEnvelope(p.StartIndex), nil
|
||||
}
|
||||
if filterBySeasonNumbers && containsItemType(p.IncludeItemTypes, "Movie") && !containsItemType(p.IncludeItemTypes, "Episode") {
|
||||
q = q.Where("season_num = 0 AND episode_num = 0")
|
||||
q = e.filterMovieItems(ctx, q)
|
||||
}
|
||||
if filterBySeasonNumbers && containsItemType(p.IncludeItemTypes, "Episode") && !containsItemType(p.IncludeItemTypes, "Movie") {
|
||||
q = q.Where("season_num > 0 OR episode_num > 0")
|
||||
q = e.filterEpisodeItems(ctx, q)
|
||||
}
|
||||
|
||||
var total int64
|
||||
@@ -519,10 +519,23 @@ func (e *EmbyService) mediaItems(ctx context.Context, p ItemsParams) (map[string
|
||||
}
|
||||
}
|
||||
|
||||
fetchLimit := p.Limit
|
||||
fetchOffset := p.StartIndex
|
||||
if fetchLimit > 0 && e.shouldCollapseMediaVersions(ctx, p) {
|
||||
// Duplicates across merged local/cloud libraries collapse into one Emby
|
||||
// item with multiple MediaSources. Fetch a wider window so duplicates do
|
||||
// not consume the whole requested page.
|
||||
fetchOffset = 0
|
||||
fetchLimit = p.StartIndex + maxInt(p.Limit*4, p.Limit)
|
||||
}
|
||||
var rows []model.Media
|
||||
if err := q.Order(order).Offset(p.StartIndex).Limit(p.Limit).Find(&rows).Error; err != nil {
|
||||
if err := q.Order(order).Offset(fetchOffset).Limit(fetchLimit).Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if e.shouldCollapseMediaVersions(ctx, p) {
|
||||
rows = e.collapseMediaVersionRows(ctx, rows)
|
||||
rows = pageSlice(rows, p.StartIndex, p.Limit)
|
||||
}
|
||||
items, err := e.payloadsForMedia(ctx, rows, p.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -610,6 +623,7 @@ func (e *EmbyService) episodeItems(ctx context.Context, rows []model.Media, p It
|
||||
}
|
||||
|
||||
func (e *EmbyService) payloadsForMedia(ctx context.Context, rows []model.Media, userID string) ([]map[string]any, error) {
|
||||
rows = e.collapseMediaVersionRows(ctx, rows)
|
||||
userFavs := map[string]bool{}
|
||||
userPos := map[string]int64{}
|
||||
if userID != "" && len(rows) > 0 {
|
||||
@@ -643,6 +657,44 @@ func (e *EmbyService) payloadsForMedia(ctx context.Context, rows []model.Media,
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (e *EmbyService) shouldCollapseMediaVersions(ctx context.Context, p ItemsParams) bool {
|
||||
if containsItemType(p.IncludeItemTypes, "Series") || containsItemType(p.IncludeItemTypes, "Season") {
|
||||
return false
|
||||
}
|
||||
if containsItemType(p.IncludeItemTypes, "Episode") && !containsItemType(p.IncludeItemTypes, "Movie") {
|
||||
return true
|
||||
}
|
||||
if p.ParentID == "" {
|
||||
return true
|
||||
}
|
||||
episodic, err := e.libraryIsEpisodic(ctx, p.ParentID)
|
||||
return err == nil && !episodic
|
||||
}
|
||||
|
||||
func (e *EmbyService) collapseMediaVersionRows(ctx context.Context, rows []model.Media) []model.Media {
|
||||
if len(rows) < 2 {
|
||||
return rows
|
||||
}
|
||||
out := make([]model.Media, 0, len(rows))
|
||||
indexByKey := make(map[string]int, len(rows))
|
||||
for _, row := range rows {
|
||||
key := e.mediaVersionKey(ctx, &row)
|
||||
if key == "" {
|
||||
out = append(out, row)
|
||||
continue
|
||||
}
|
||||
if idx, ok := indexByKey[key]; ok {
|
||||
if preferMediaVersion(row, out[idx]) {
|
||||
out[idx] = row
|
||||
}
|
||||
continue
|
||||
}
|
||||
indexByKey[key] = len(out)
|
||||
out = append(out, row)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Item 单条目详情。
|
||||
func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[string]any, error) {
|
||||
if lib, err := e.repo.Library.FindByID(ctx, mediaID); err != nil {
|
||||
@@ -908,7 +960,7 @@ func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool,
|
||||
"Played": played,
|
||||
"PlayedPercentage": pct,
|
||||
},
|
||||
"MediaSources": []map[string]any{e.mediaSource(ctx, m, true, false)},
|
||||
"MediaSources": e.mediaSourcesForItem(ctx, m, true, false),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -984,6 +1036,35 @@ func embyLibraryTypeIsEpisodic(typ string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func (e *EmbyService) filterMovieItems(ctx context.Context, q *gorm.DB) *gorm.DB {
|
||||
episodicIDs := e.episodicLibraryIDs(ctx)
|
||||
if len(episodicIDs) == 0 {
|
||||
return q
|
||||
}
|
||||
return q.Where("(media.season_num = 0 AND media.episode_num = 0) OR media.library_id NOT IN ?", episodicIDs)
|
||||
}
|
||||
|
||||
func (e *EmbyService) filterEpisodeItems(ctx context.Context, q *gorm.DB) *gorm.DB {
|
||||
episodicIDs := e.episodicLibraryIDs(ctx)
|
||||
if len(episodicIDs) == 0 {
|
||||
return q.Where("1 = 0")
|
||||
}
|
||||
return q.Where("media.library_id IN ? AND (media.season_num > 0 OR media.episode_num > 0)", episodicIDs)
|
||||
}
|
||||
|
||||
func (e *EmbyService) episodicLibraryIDs(ctx context.Context) []string {
|
||||
if e == nil || e.repo == nil || e.repo.DB == nil {
|
||||
return nil
|
||||
}
|
||||
var ids []string
|
||||
if err := e.repo.DB.WithContext(ctx).Model(&model.Library{}).
|
||||
Where("LOWER(type) IN ?", []string{"tv", "anime", "variety"}).
|
||||
Pluck("id", &ids).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func (e *EmbyService) rememberSeriesGroup(group embySeriesGroup) {
|
||||
if e == nil || strings.TrimSpace(group.ID) == "" {
|
||||
return
|
||||
@@ -1712,7 +1793,7 @@ func (e *EmbyService) PlaybackInfo(ctx context.Context, mediaID, userID string)
|
||||
}
|
||||
e.ensureCloudTrackMetadata(ctx, m)
|
||||
return map[string]any{
|
||||
"MediaSources": []map[string]any{e.mediaSource(ctx, m, false, e.directPlayOnly(ctx))},
|
||||
"MediaSources": e.mediaSourcesForItem(ctx, m, false, e.directPlayOnly(ctx)),
|
||||
"PlaySessionId": fmt.Sprintf("%s-%d", m.ID, time.Now().Unix()),
|
||||
}, nil
|
||||
}
|
||||
@@ -1936,6 +2017,124 @@ func (e *EmbyService) mediaSource(ctx context.Context, m *model.Media, asEmbedde
|
||||
return src
|
||||
}
|
||||
|
||||
func (e *EmbyService) mediaSourcesForItem(ctx context.Context, m *model.Media, asEmbedded, directOnly bool) []map[string]any {
|
||||
siblings := e.mediaVersionSiblings(ctx, m)
|
||||
if len(siblings) == 0 {
|
||||
return []map[string]any{e.mediaSource(ctx, m, asEmbedded, directOnly)}
|
||||
}
|
||||
sources := make([]map[string]any, 0, len(siblings))
|
||||
for i := range siblings {
|
||||
media := siblings[i]
|
||||
sources = append(sources, e.mediaSource(ctx, &media, asEmbedded, directOnly))
|
||||
}
|
||||
return sources
|
||||
}
|
||||
|
||||
func (e *EmbyService) mediaVersionSiblings(ctx context.Context, m *model.Media) []model.Media {
|
||||
if e == nil || e.repo == nil || e.repo.DB == nil || m == nil || strings.TrimSpace(m.ID) == "" {
|
||||
return nil
|
||||
}
|
||||
libraryIDs := e.mergedLibraryIDs(ctx, m.LibraryID)
|
||||
if len(libraryIDs) == 0 {
|
||||
libraryIDs = []string{m.LibraryID}
|
||||
}
|
||||
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).
|
||||
Where("library_id IN ?", libraryIDs).
|
||||
Where("season_num = ? AND episode_num = ?", m.SeasonNum, m.EpisodeNum)
|
||||
if m.TMDbID > 0 {
|
||||
q = q.Where("tm_db_id = ?", m.TMDbID)
|
||||
} else if m.BangumiID > 0 {
|
||||
q = q.Where("bangumi_id = ?", m.BangumiID)
|
||||
} else {
|
||||
title := strings.TrimSpace(m.Title)
|
||||
if title == "" {
|
||||
title = strings.TrimSpace(m.OriginalName)
|
||||
}
|
||||
if title == "" {
|
||||
return []model.Media{*m}
|
||||
}
|
||||
q = q.Where("LOWER(title) = ?", strings.ToLower(title))
|
||||
if m.Year > 0 {
|
||||
q = q.Where("year = ?", m.Year)
|
||||
}
|
||||
}
|
||||
var rows []model.Media
|
||||
if err := q.Find(&rows).Error; err != nil || len(rows) == 0 {
|
||||
return []model.Media{*m}
|
||||
}
|
||||
rows = e.collapseExactPathRows(rows)
|
||||
sort.SliceStable(rows, func(i, j int) bool {
|
||||
if rows[i].ID == m.ID {
|
||||
return true
|
||||
}
|
||||
if rows[j].ID == m.ID {
|
||||
return false
|
||||
}
|
||||
return preferMediaVersion(rows[i], rows[j])
|
||||
})
|
||||
return rows
|
||||
}
|
||||
|
||||
func (e *EmbyService) collapseExactPathRows(rows []model.Media) []model.Media {
|
||||
if len(rows) < 2 {
|
||||
return rows
|
||||
}
|
||||
out := rows[:0]
|
||||
seen := map[string]struct{}{}
|
||||
for _, row := range rows {
|
||||
path := strings.TrimSpace(row.Path)
|
||||
if path != "" {
|
||||
if _, ok := seen[path]; ok {
|
||||
continue
|
||||
}
|
||||
seen[path] = struct{}{}
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (e *EmbyService) mediaVersionKey(ctx context.Context, m *model.Media) string {
|
||||
if e == nil || m == nil {
|
||||
return ""
|
||||
}
|
||||
ids := e.mergedLibraryIDs(ctx, m.LibraryID)
|
||||
sort.Strings(ids)
|
||||
libraryGroup := strings.Join(ids, ",")
|
||||
if libraryGroup == "" {
|
||||
libraryGroup = strings.TrimSpace(m.LibraryID)
|
||||
}
|
||||
if m.TMDbID > 0 {
|
||||
return fmt.Sprintf("%s|tmdb:%d|s:%d|e:%d", libraryGroup, m.TMDbID, m.SeasonNum, m.EpisodeNum)
|
||||
}
|
||||
if m.BangumiID > 0 {
|
||||
return fmt.Sprintf("%s|bangumi:%d|s:%d|e:%d", libraryGroup, m.BangumiID, m.SeasonNum, m.EpisodeNum)
|
||||
}
|
||||
title := strings.ToLower(strings.TrimSpace(m.Title))
|
||||
if title == "" {
|
||||
title = strings.ToLower(strings.TrimSpace(m.OriginalName))
|
||||
}
|
||||
if title == "" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%s|title:%s|y:%d|s:%d|e:%d", libraryGroup, title, m.Year, m.SeasonNum, m.EpisodeNum)
|
||||
}
|
||||
|
||||
func preferMediaVersion(candidate, current model.Media) bool {
|
||||
candidateCloud := strings.TrimSpace(candidate.STRMURL) != "" || strings.HasPrefix(strings.ToLower(strings.TrimSpace(candidate.Path)), "cloud://")
|
||||
currentCloud := strings.TrimSpace(current.STRMURL) != "" || strings.HasPrefix(strings.ToLower(strings.TrimSpace(current.Path)), "cloud://")
|
||||
if candidateCloud != currentCloud {
|
||||
return !candidateCloud
|
||||
}
|
||||
if candidate.Width != current.Width {
|
||||
return candidate.Width > current.Width
|
||||
}
|
||||
if candidate.SizeBytes != current.SizeBytes {
|
||||
return candidate.SizeBytes > current.SizeBytes
|
||||
}
|
||||
return candidate.CreatedAt.After(current.CreatedAt)
|
||||
}
|
||||
|
||||
func embySTRMStreamURL(mediaID string) string {
|
||||
return "/api/stream/" + url.PathEscape(strings.TrimSpace(mediaID))
|
||||
}
|
||||
|
||||
@@ -228,7 +228,7 @@ func TestEmbyCloudAnimeUsesSeriesNameFromChineseSeasonFolder(t *testing.T) {
|
||||
|
||||
func TestEmbyMovieLibrarySeasonNumbersStayMovies(t *testing.T) {
|
||||
svc := newTestEmbyService(t)
|
||||
lib := model.Library{Name: "动画电影", Path: `/media/movies/animation`, Type: "movie", Enabled: true}
|
||||
lib := model.Library{Name: "动画电影", Path: `/media/movies/animation`, Type: "Movie", Enabled: true}
|
||||
if err := svc.repo.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatalf("create library: %v", err)
|
||||
}
|
||||
@@ -276,6 +276,84 @@ func TestEmbyMovieLibrarySeasonNumbersStayMovies(t *testing.T) {
|
||||
if item["Type"] != "Movie" || item["ParentId"] != lib.ID {
|
||||
t.Fatalf("direct item should stay Movie, got %#v", item)
|
||||
}
|
||||
|
||||
rootMovies, err := svc.Items(t.Context(), ItemsParams{IncludeItemTypes: []string{"Movie"}, Recursive: true, Limit: 50})
|
||||
if err != nil {
|
||||
t.Fatalf("root movie query: %v", err)
|
||||
}
|
||||
rootItems := rootMovies["Items"].([]map[string]any)
|
||||
if len(rootItems) != 1 || rootItems[0]["Id"] != media.ID || rootItems[0]["Type"] != "Movie" {
|
||||
t.Fatalf("root movie query should include movie-library item despite season numbers, got %#v", rootItems)
|
||||
}
|
||||
|
||||
rootEpisodes, err := svc.Items(t.Context(), ItemsParams{IncludeItemTypes: []string{"Episode"}, Recursive: true, Limit: 50})
|
||||
if err != nil {
|
||||
t.Fatalf("root episode query: %v", err)
|
||||
}
|
||||
if len(rootEpisodes["Items"].([]map[string]any)) != 0 {
|
||||
t.Fatalf("root episode query should not expose movie-library item, got %#v", rootEpisodes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyMergedLocalCloudMovieVersionsShareMediaSources(t *testing.T) {
|
||||
svc := newTestEmbyService(t)
|
||||
local := model.Library{Name: "国产电影", Path: `/media/国产电影`, Type: "movie", Enabled: true}
|
||||
cloud := model.Library{Name: "OpenList · 国产电影", Path: BuildCloudLibraryPath("openlist", "/国产电影", "/国产电影"), Type: "movie", Enabled: true}
|
||||
for _, lib := range []*model.Library{&local, &cloud} {
|
||||
if err := svc.repo.Library.Create(t.Context(), lib); err != nil {
|
||||
t.Fatalf("create library: %v", err)
|
||||
}
|
||||
}
|
||||
for _, media := range []model.Media{
|
||||
{
|
||||
Base: model.Base{ID: "local-version", CreatedAt: time.Now()},
|
||||
LibraryID: local.ID,
|
||||
Title: "流浪地球",
|
||||
Year: 2019,
|
||||
Path: `/media/国产电影/流浪地球.2019.1080p.mkv`,
|
||||
Container: "mkv",
|
||||
Width: 1920,
|
||||
},
|
||||
{
|
||||
Base: model.Base{ID: "cloud-version", CreatedAt: time.Now().Add(time.Minute)},
|
||||
LibraryID: cloud.ID,
|
||||
Title: "流浪地球",
|
||||
Year: 2019,
|
||||
Path: `cloud://openlist/国产电影/流浪地球.2019.2160p.mkv`,
|
||||
Container: "mkv",
|
||||
STRMURL: "https://example.invalid/cloud",
|
||||
Width: 3840,
|
||||
},
|
||||
} {
|
||||
if err := svc.repo.DB.Create(&media).Error; err != nil {
|
||||
t.Fatalf("create media: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
items, err := svc.Items(t.Context(), ItemsParams{ParentID: local.ID, IncludeItemTypes: []string{"Movie"}, Recursive: true, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("items: %v", err)
|
||||
}
|
||||
rows := items["Items"].([]map[string]any)
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("merged local/cloud versions should show as one item, got %#v", rows)
|
||||
}
|
||||
if rows[0]["Id"] != "local-version" {
|
||||
t.Fatalf("local media should be the representative item, got %#v", rows[0])
|
||||
}
|
||||
sources := rows[0]["MediaSources"].([]map[string]any)
|
||||
if len(sources) != 2 {
|
||||
t.Fatalf("merged item should expose two media sources, got %#v", sources)
|
||||
}
|
||||
|
||||
playback, err := svc.PlaybackInfo(t.Context(), "local-version", "user-1")
|
||||
if err != nil {
|
||||
t.Fatalf("playback: %v", err)
|
||||
}
|
||||
playSources := playback["MediaSources"].([]map[string]any)
|
||||
if len(playSources) != 2 {
|
||||
t.Fatalf("playback should expose local and cloud versions, got %#v", playSources)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyRootItemsExposeLibraries(t *testing.T) {
|
||||
|
||||
@@ -21,6 +21,7 @@ const (
|
||||
EventDownloadComplete = "download_complete"
|
||||
EventScrapeFailed = "scrape_failed"
|
||||
EventSystemAlert = "system_alert"
|
||||
EventLibraryIngest = "library_ingest"
|
||||
)
|
||||
|
||||
// NotifyEvent 是通知事件的数据结构。
|
||||
@@ -43,7 +44,7 @@ type NotifyService struct {
|
||||
repo *repository.Container
|
||||
crypto *CryptoService
|
||||
|
||||
mu sync.RWMutex
|
||||
mu sync.RWMutex
|
||||
providers map[string]NotifyProvider // type -> provider
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ import (
|
||||
)
|
||||
|
||||
// videoExtensions lists the file extensions treated as media. Matches the
|
||||
// MediaStation Python defaults.
|
||||
// legacy Python defaults.
|
||||
var videoExtensions = map[string]struct{}{
|
||||
".mkv": {},
|
||||
".mp4": {},
|
||||
@@ -57,6 +57,7 @@ type ScannerService struct {
|
||||
scraper *ScraperService
|
||||
storage *StorageConfigService
|
||||
cache *RuntimeCacheService
|
||||
notify *NotifyChannelService
|
||||
|
||||
imageProxy *ImageProxy
|
||||
|
||||
@@ -78,6 +79,8 @@ type ScannerService struct {
|
||||
localMediaProbeQueue chan localMediaProbeTask
|
||||
localMediaProbeMu sync.Mutex
|
||||
localMediaProbing map[string]struct{}
|
||||
localScanMu sync.Mutex
|
||||
localScans map[string]struct{}
|
||||
}
|
||||
|
||||
// NewScannerService is the constructor.
|
||||
@@ -102,6 +105,7 @@ func NewScannerService(
|
||||
cloudMediaProbeBackoff: make(map[string]time.Time),
|
||||
localMediaProbeQueue: make(chan localMediaProbeTask, 1024),
|
||||
localMediaProbing: make(map[string]struct{}),
|
||||
localScans: make(map[string]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,6 +130,12 @@ func (s *ScannerService) SetRuntimeCache(cache *RuntimeCacheService) {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ScannerService) SetNotifyChannels(notify *NotifyChannelService) {
|
||||
if s != nil {
|
||||
s.notify = notify
|
||||
}
|
||||
}
|
||||
|
||||
// SetImageProxy lets cloud scans warm sidecar poster/backdrop files into the
|
||||
// local image cache. This keeps library opening fast without forcing the UI or
|
||||
// Emby clients to resolve/download every cloud poster on demand.
|
||||
@@ -281,6 +291,7 @@ type ScanResult struct {
|
||||
}
|
||||
|
||||
var ErrCloudScanAlreadyRunning = errors.New("cloud scan already running")
|
||||
var ErrLocalScanAlreadyRunning = errors.New("local scan already running")
|
||||
|
||||
const maxScanErrorDetails = 20
|
||||
|
||||
@@ -594,6 +605,7 @@ func (s *ScannerService) beginCloudScan(ctx context.Context, lib *model.Library,
|
||||
"errors": current.status.Errors,
|
||||
})
|
||||
}
|
||||
s.notifyScanFinished(lib, res, err, true)
|
||||
}
|
||||
return runCtx, finish, nil
|
||||
}
|
||||
@@ -832,6 +844,27 @@ func (s *ScannerService) ScanLibraryWithoutAutoScrape(ctx context.Context, libra
|
||||
return s.scanLibrary(ctx, libraryID, false)
|
||||
}
|
||||
|
||||
func (s *ScannerService) TryBeginLocalScan(libraryID string) (func(), bool) {
|
||||
if s == nil || strings.TrimSpace(libraryID) == "" {
|
||||
return func() {}, true
|
||||
}
|
||||
s.localScanMu.Lock()
|
||||
if s.localScans == nil {
|
||||
s.localScans = make(map[string]struct{})
|
||||
}
|
||||
if _, ok := s.localScans[libraryID]; ok {
|
||||
s.localScanMu.Unlock()
|
||||
return nil, false
|
||||
}
|
||||
s.localScans[libraryID] = struct{}{}
|
||||
s.localScanMu.Unlock()
|
||||
return func() {
|
||||
s.localScanMu.Lock()
|
||||
delete(s.localScans, libraryID)
|
||||
s.localScanMu.Unlock()
|
||||
}, true
|
||||
}
|
||||
|
||||
func (s *ScannerService) scanLibrary(ctx context.Context, libraryID string, autoScrape bool) (*ScanResult, error) {
|
||||
lib, err := s.repo.Library.FindByID(ctx, libraryID)
|
||||
if err != nil || lib == nil {
|
||||
@@ -940,6 +973,7 @@ func (s *ScannerService) scanLibrary(ctx context.Context, libraryID string, auto
|
||||
"error_count": res.ErrorCount,
|
||||
"errors": res.Errors,
|
||||
})
|
||||
s.notifyScanFinished(lib, res, nil, false)
|
||||
s.invalidateMediaCache(ctx)
|
||||
s.maybeGenerateSTRMAfterScan(lib.ID)
|
||||
|
||||
@@ -951,6 +985,33 @@ func (s *ScannerService) scanLibrary(ctx context.Context, libraryID string, auto
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (s *ScannerService) notifyScanFinished(lib *model.Library, res *ScanResult, err error, cloud bool) {
|
||||
if s == nil || s.notify == nil || lib == nil || res == nil {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
s.notify.Broadcast(ctx, "MediaStationGo 扫描异常", fmt.Sprintf("媒体库:%s\n错误:%s", lib.Name, err.Error()), EventSystemAlert)
|
||||
}()
|
||||
return
|
||||
}
|
||||
if res.Added+res.Updated <= 0 {
|
||||
return
|
||||
}
|
||||
source := "本地媒体库"
|
||||
if cloud {
|
||||
source = "网盘媒体库"
|
||||
}
|
||||
body := fmt.Sprintf("%s:%s\n新增:%d\n更新:%d\n跳过:%d\n移除:%d", source, lib.Name, res.Added, res.Updated, res.Skipped, res.Removed)
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
s.notify.Broadcast(ctx, "MediaStationGo 入库完成", body, EventLibraryIngest)
|
||||
}()
|
||||
}
|
||||
|
||||
// IngestPath ingests a single file into the given library without walking the
|
||||
// whole tree. Used by the watcher for incremental, event-driven additions so
|
||||
// adding one new file no longer triggers a full library re-scan (减少硬盘损耗).
|
||||
|
||||
@@ -38,6 +38,7 @@ type ScraperService struct {
|
||||
fanart *FanartProvider
|
||||
adult *AdultProvider
|
||||
hub *Hub
|
||||
notify *NotifyChannelService
|
||||
}
|
||||
|
||||
// NewScraperService is the constructor.
|
||||
@@ -66,6 +67,12 @@ func (s *ScraperService) SetDouban(douban *DoubanProvider) {
|
||||
s.douban = douban
|
||||
}
|
||||
|
||||
func (s *ScraperService) SetNotifyChannels(notify *NotifyChannelService) {
|
||||
if s != nil {
|
||||
s.notify = notify
|
||||
}
|
||||
}
|
||||
|
||||
// yearPattern extracts a 4-digit year (1900-2099).
|
||||
var yearPattern = regexp.MustCompile(`(?:^|[^\d])(19\d{2}|20\d{2})(?:[^\d]|$)`)
|
||||
|
||||
@@ -780,6 +787,7 @@ func (s *ScraperService) EnrichLibrary(ctx context.Context, libraryID string, re
|
||||
}
|
||||
if err := s.EnrichOne(ctx, &rows[i]); err != nil {
|
||||
s.log.Warn("enrich failed", zap.String("media", rows[i].ID), zap.Error(err))
|
||||
s.notifyScrapeFailed(rows[i], err)
|
||||
continue
|
||||
}
|
||||
processed++
|
||||
@@ -805,6 +813,22 @@ func (s *ScraperService) EnrichLibrary(ctx context.Context, libraryID string, re
|
||||
return matched, nil
|
||||
}
|
||||
|
||||
func (s *ScraperService) notifyScrapeFailed(m model.Media, err error) {
|
||||
if s == nil || s.notify == nil || err == nil {
|
||||
return
|
||||
}
|
||||
body := strings.TrimSpace(m.Title)
|
||||
if body == "" {
|
||||
body = m.Path
|
||||
}
|
||||
body = "媒体:" + body + "\n错误:" + err.Error()
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
s.notify.Broadcast(ctx, "MediaStationGo 刮削失败", body, EventScrapeFailed)
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *ScraperService) scrapeDelay(ctx context.Context) time.Duration {
|
||||
minMS := s.scrapeDelaySetting(ctx, "scrape.delay_min_ms", defaultScrapeDelayMinMS)
|
||||
maxMS := s.scrapeDelaySetting(ctx, "scrape.delay_max_ms", defaultScrapeDelayMaxMS)
|
||||
|
||||
@@ -129,6 +129,8 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
|
||||
backup := NewBackupService(cfg, log, repos.DB)
|
||||
notifier := NewNotifierService(log, repos)
|
||||
notifyChannels := NewNotifyChannelService(log, repos)
|
||||
scanner.SetNotifyChannels(notifyChannels)
|
||||
scraper.SetNotifyChannels(notifyChannels)
|
||||
playProfiles := NewPlayProfileService(log, repos)
|
||||
permissions := NewPermissionService(log, repos)
|
||||
storageCfg := NewStorageConfigService(log, repos, crypto)
|
||||
@@ -166,8 +168,10 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
|
||||
downloads.SetScanner(scanner)
|
||||
downloads.SetTaskTracker(tasks)
|
||||
downloads.SetOrganizePipeline(organizePipeline)
|
||||
downloads.SetNotifyChannels(notifyChannels)
|
||||
subscription := NewSubscriptionService(cfg, log, repos, downloads, siteSvc, hub)
|
||||
subscription.SetScraper(scraper)
|
||||
subscription.SetNotifyChannels(notifyChannels)
|
||||
|
||||
// 让图片代理把媒体库根目录视为可读的本地图片位置:海报/封面等
|
||||
// sidecar 资源就存放在这些(用户自定义、任意)目录下,否则会被
|
||||
|
||||
@@ -218,23 +218,23 @@ func (s *SiteService) FindByID(ctx context.Context, id string) (*model.Site, err
|
||||
// upload_bytes, download_bytes are excluded to prevent injection.
|
||||
var siteUpdatableFields = map[string]bool{
|
||||
"name": true,
|
||||
"url": true,
|
||||
"type": true,
|
||||
"auth_type": true,
|
||||
"api_key": true,
|
||||
"cookie": true,
|
||||
"auth_header": true,
|
||||
"user_agent": true,
|
||||
"rss_url": true,
|
||||
"timeout": true,
|
||||
"priority": true,
|
||||
"use_proxy": true,
|
||||
"rate_limit": true,
|
||||
"url": true,
|
||||
"type": true,
|
||||
"auth_type": true,
|
||||
"api_key": true,
|
||||
"cookie": true,
|
||||
"auth_header": true,
|
||||
"user_agent": true,
|
||||
"rss_url": true,
|
||||
"timeout": true,
|
||||
"priority": true,
|
||||
"use_proxy": true,
|
||||
"rate_limit": true,
|
||||
"browser_emulation": true,
|
||||
"downloader": true,
|
||||
"enabled": true,
|
||||
"is_default": true,
|
||||
"extra": true,
|
||||
"downloader": true,
|
||||
"enabled": true,
|
||||
"is_default": true,
|
||||
"extra": true,
|
||||
}
|
||||
|
||||
// Update applies a partial patch to an existing site.
|
||||
@@ -270,7 +270,7 @@ func (s *SiteService) Delete(ctx context.Context, id string) error {
|
||||
// TestConnection tries to reach the site's base URL with the configured
|
||||
// credentials and reports success/failure.
|
||||
//
|
||||
// 测试逻辑(与参考项目 ShukeBta/MediaStation 对齐):
|
||||
// 测试逻辑(与旧版参考实现对齐):
|
||||
//
|
||||
// 1. 优先调用对应站点适配器的 Authenticate(),让 PT 站点(M-Team / UNIT3D /
|
||||
// Gazelle 等)使用各自的开放 API 验证,而不是去拉首页 HTML——后者通常
|
||||
|
||||
@@ -119,7 +119,7 @@ func buildRequest(ctx context.Context, method, rawURL string, cfg SiteConfig, bo
|
||||
req.Header.Set("Cookie", cfg.Cookie)
|
||||
}
|
||||
case "api_key":
|
||||
// 与参考项目(ShukeBta/MediaStation)的 ApplySiteAuthHeaders 对齐:
|
||||
// 与旧版参考实现的 ApplySiteAuthHeaders 对齐:
|
||||
// M-Team / UNIT3D 等开放 API 的 PT 站点都使用 `x-api-key` 头部,
|
||||
// 不要再为 mteam 单独走 Authorization: Bearer,否则服务端会 401。
|
||||
if cfg.APIKey != "" {
|
||||
|
||||
@@ -30,7 +30,7 @@ func (a *MTeamAdapter) Authenticate(ctx context.Context, cfg SiteConfig) error {
|
||||
if strings.TrimSpace(cfg.APIKey) == "" {
|
||||
return fmt.Errorf("M-Team 需要填写 API Access Token(控制台 → 实验室 → 存取令牌),不能使用 Cookie 访问开放 API")
|
||||
}
|
||||
// 与 ShukeBta/MediaStation 参考实现对齐:
|
||||
// 与旧版参考实现对齐:
|
||||
// 用 camelCase 参数(pageNumber / pageSize),同时接受 code 为字符串 "0"
|
||||
// 或数值 0;兼容 M-Team v3 API 不同版本的返回。
|
||||
u := cfg.URL + "/api/torrent/search"
|
||||
@@ -181,8 +181,8 @@ func (a *MTeamAdapter) GetDetail(ctx context.Context, cfg SiteConfig, id string)
|
||||
// POST /api/torrent/genDlToken?id={tid} (带 x-api-key)
|
||||
// → {"code":"0","data":"https://api.m-team.cc/api/rss/dlv2?sign=..."}
|
||||
//
|
||||
// 拿到的 sign URL 可被任何下载客户端无认证地直接 GET。这是参考项目
|
||||
// (ShukeBta/MediaStation) 的 _download_torrent_file 方法的子集。
|
||||
// 拿到的 sign URL 可被任何下载客户端无认证地直接 GET。这是旧版参考实现
|
||||
// _download_torrent_file 方法的子集。
|
||||
func (a *MTeamAdapter) GetDownloadURL(ctx context.Context, cfg SiteConfig, id string) (string, error) {
|
||||
u := cfg.URL + "/api/torrent/genDlToken?id=" + id
|
||||
// genDlToken 是 POST 但参数走 query string;body 留空。
|
||||
@@ -220,7 +220,7 @@ func (a *MTeamAdapter) GetDownloadURL(ctx context.Context, cfg SiteConfig, id st
|
||||
|
||||
// parseMTeamJSON 解析 MTeam v3 JSON 响应。
|
||||
//
|
||||
// 响应结构(与 ShukeBta/MediaStation 参考项目一致):
|
||||
// 响应结构(与旧版参考实现一致):
|
||||
//
|
||||
// {
|
||||
// "code": "0", // 字符串 "0" 表示成功
|
||||
|
||||
@@ -34,6 +34,7 @@ type SubscriptionService struct {
|
||||
site *SiteService
|
||||
scraper *ScraperService
|
||||
hub *Hub
|
||||
notify *NotifyChannelService
|
||||
stop chan struct{}
|
||||
}
|
||||
|
||||
@@ -54,6 +55,10 @@ func (s *SubscriptionService) SetScraper(scraper *ScraperService) {
|
||||
s.scraper = scraper
|
||||
}
|
||||
|
||||
func (s *SubscriptionService) SetNotifyChannels(notify *NotifyChannelService) {
|
||||
s.notify = notify
|
||||
}
|
||||
|
||||
// Start runs the polling loop in the background.
|
||||
func (s *SubscriptionService) Start(ctx context.Context) {
|
||||
go s.loop(ctx)
|
||||
@@ -272,6 +277,7 @@ func (s *SubscriptionService) runOne(ctx context.Context, sub *model.Subscriptio
|
||||
"name": sub.Name,
|
||||
"queued": queued,
|
||||
})
|
||||
s.notifySubscriptionHit(sub, queued, nil)
|
||||
}
|
||||
return queued, nil
|
||||
}
|
||||
@@ -377,6 +383,7 @@ func (s *SubscriptionService) runSiteSearch(ctx context.Context, sub *model.Subs
|
||||
"keyword": keyword,
|
||||
"resources": resources,
|
||||
})
|
||||
s.notifySubscriptionHit(sub, queued, resources)
|
||||
return queued, nil
|
||||
}
|
||||
if lastEnqueueErr != nil {
|
||||
@@ -385,6 +392,21 @@ func (s *SubscriptionService) runSiteSearch(ctx context.Context, sub *model.Subs
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (s *SubscriptionService) notifySubscriptionHit(sub *model.Subscription, queued int, resources []string) {
|
||||
if s == nil || s.notify == nil || sub == nil || queued <= 0 {
|
||||
return
|
||||
}
|
||||
body := fmt.Sprintf("订阅:%s\n新增资源:%d", sub.Name, queued)
|
||||
if len(resources) > 0 {
|
||||
body += "\n资源:\n- " + strings.Join(resources, "\n- ")
|
||||
}
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
s.notify.Broadcast(ctx, "MediaStationGo 订阅命中新资源", body, EventSubscriptionHit)
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *SubscriptionService) archiveCompletedSubscription(ctx context.Context, sub *model.Subscription, availability LocalAvailability) error {
|
||||
if s == nil || s.repo == nil || s.repo.Subscription == nil || sub == nil {
|
||||
return nil
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// converts SRT to WebVTT on the fly so the browser <track> element can
|
||||
// load them directly.
|
||||
//
|
||||
// External-subtitle discovery rules (matching MediaStation Python defaults):
|
||||
// External-subtitle discovery rules (matching the legacy Python defaults):
|
||||
//
|
||||
// 1. Same directory, same basename, different extension.
|
||||
// 2. Same directory, ".sub/" or "subs/" subdirectory.
|
||||
|
||||
@@ -272,7 +272,7 @@ func TestTelegramReplyAutoDeletesSentMessage(t *testing.T) {
|
||||
waitForTelegramMethod(t, requests, "deleteMessage")
|
||||
}
|
||||
|
||||
func TestTelegramGroupCommandSendsPanelPrivately(t *testing.T) {
|
||||
func TestTelegramGroupCommandSendsPanelInGroup(t *testing.T) {
|
||||
var payloads []struct {
|
||||
ChatID any `json:"chat_id"`
|
||||
Text string `json:"text"`
|
||||
@@ -319,23 +319,14 @@ func TestTelegramGroupCommandSendsPanelPrivately(t *testing.T) {
|
||||
if err := bot.HandleWebhook(t.Context(), update); err != nil {
|
||||
t.Fatalf("handle webhook: %v", err)
|
||||
}
|
||||
if len(payloads) != 2 {
|
||||
if len(payloads) != 1 {
|
||||
t.Fatalf("sendMessage count = %d, payloads=%#v", len(payloads), payloads)
|
||||
}
|
||||
if got := fmt.Sprint(payloads[0].ChatID); got != "9002" {
|
||||
t.Fatalf("first message should be private to requester, chat_id=%s payload=%#v", got, payloads[0])
|
||||
if got := fmt.Sprint(payloads[0].ChatID); got != "-100123" {
|
||||
t.Fatalf("message should stay in group, chat_id=%s payload=%#v", got, payloads[0])
|
||||
}
|
||||
if payloads[0].ReplyMarkup == nil {
|
||||
t.Fatalf("private panel should include inline keyboard: %#v", payloads[0])
|
||||
}
|
||||
if got := fmt.Sprint(payloads[1].ChatID); got != "-100123" {
|
||||
t.Fatalf("second message should be group ack, chat_id=%s payload=%#v", got, payloads[1])
|
||||
}
|
||||
if payloads[1].ReplyMarkup != nil {
|
||||
t.Fatalf("group ack must not expose buttons: %#v", payloads[1])
|
||||
}
|
||||
if !strings.Contains(payloads[1].Text, "私聊") {
|
||||
t.Fatalf("group ack should explain private delivery, got %q", payloads[1].Text)
|
||||
if strings.Contains(payloads[0].Text, "管理员入口") {
|
||||
t.Fatalf("normal group user must not see admin panel: %#v", payloads[0])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -981,20 +981,7 @@ func (s *TelegramBotService) replyForMessage(ctx context.Context, channel *model
|
||||
if strings.TrimSpace(reply.Text) == "" {
|
||||
return nil
|
||||
}
|
||||
if !telegramIsGroupChat(msg.Chat.Type) {
|
||||
return s.reply(ctx, channel, msg.Chat.ID, reply)
|
||||
}
|
||||
if err := s.reply(ctx, channel, msg.From.ID, reply); err != nil {
|
||||
if s.log != nil {
|
||||
s.log.Warn("telegram private reply from group failed",
|
||||
zap.Int("group_chat_id", msg.Chat.ID),
|
||||
zap.Int("telegram_user_id", msg.From.ID),
|
||||
zap.Error(sanitizeTelegramError(err)),
|
||||
)
|
||||
}
|
||||
return s.reply(ctx, channel, msg.Chat.ID, telegramCommandReply{Text: telegramGroupPrivateDeliveryFailedHint()})
|
||||
}
|
||||
return s.reply(ctx, channel, msg.Chat.ID, telegramCommandReply{Text: telegramGroupPrivateDeliverySentHint()})
|
||||
return s.reply(ctx, channel, msg.Chat.ID, reply)
|
||||
}
|
||||
|
||||
func (s *TelegramBotService) deleteTelegramSourceMessage(channel *model.NotifyChannel, chatID, messageID int) {
|
||||
|
||||
@@ -165,7 +165,7 @@ func TestTelegramGroupHidesAdminPanelFromRegularUsers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramGroupAdminMenuDoesNotExposeButtonsInGroup(t *testing.T) {
|
||||
func TestTelegramGroupAdminMenuExposesButtonsOnlyToAdmins(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
repos, bot := newBotTestService(t)
|
||||
admin := &model.User{Username: "root", PasswordHash: "x", Role: "admin", IsActive: true}
|
||||
@@ -179,19 +179,32 @@ func TestTelegramGroupAdminMenuDoesNotExposeButtonsInGroup(t *testing.T) {
|
||||
}
|
||||
|
||||
menu := bot.mainMenu(ctx, channel, msg)
|
||||
if telegramReplyHasButtonPrefix(menu, "adm_") {
|
||||
t.Fatalf("admin group menu must not expose admin buttons publicly: %#v", menu.Buttons)
|
||||
if !telegramReplyHasButtonPrefix(menu, "adm_") {
|
||||
t.Fatalf("admin group menu should expose admin buttons, got %#v", menu.Buttons)
|
||||
}
|
||||
if !strings.Contains(menu.Text, "请私聊 Bot") {
|
||||
t.Fatalf("admin group menu should tell admins to use private chat, got %q", menu.Text)
|
||||
if !strings.Contains(menu.Text, "管理员入口") {
|
||||
t.Fatalf("admin group menu should label admin section, got %q", menu.Text)
|
||||
}
|
||||
|
||||
reply, handled := bot.handleMenuCallback(ctx, channel, msg, "adm_users")
|
||||
if !handled {
|
||||
t.Fatal("admin callback should be handled")
|
||||
}
|
||||
if !strings.Contains(reply.Text, "请私聊 Bot") || telegramReplyHasButtonPrefix(reply, "adm_") {
|
||||
t.Fatalf("group admin callback should not render admin panel publicly: %#v", reply)
|
||||
if !strings.Contains(reply.Text, "用户管理") {
|
||||
t.Fatalf("group admin callback should render admin panel, got %#v", reply)
|
||||
}
|
||||
|
||||
normal := &TelegramMessage{
|
||||
From: TelegramUser{ID: 9002, Username: "viewer", FirstName: "Viewer"},
|
||||
Chat: TelegramChat{ID: -100123, Type: "group"},
|
||||
}
|
||||
normalMenu := bot.mainMenu(ctx, channel, normal)
|
||||
if telegramReplyHasButtonPrefix(normalMenu, "adm_") || strings.Contains(normalMenu.Text, "管理员入口") {
|
||||
t.Fatalf("normal group user must not see admin controls: %#v", normalMenu)
|
||||
}
|
||||
normalReply, handled := bot.handleMenuCallback(ctx, channel, normal, "adm_users")
|
||||
if !handled || normalReply.Text != "" || len(normalReply.Buttons) != 0 {
|
||||
t.Fatalf("normal group user must not use admin callbacks: %#v handled=%v", normalReply, handled)
|
||||
}
|
||||
|
||||
reply, err := bot.executeCommand(ctx, channel, msg, "/users")
|
||||
@@ -201,8 +214,8 @@ func TestTelegramGroupAdminMenuDoesNotExposeButtonsInGroup(t *testing.T) {
|
||||
if !strings.Contains(reply.Text, "用户管理") {
|
||||
t.Fatalf("bound group admin text command should run, got %q", reply.Text)
|
||||
}
|
||||
if len(reply.Buttons) != 0 {
|
||||
t.Fatalf("group admin text command must not expose inline buttons publicly: %#v", reply.Buttons)
|
||||
if len(reply.Buttons) == 0 {
|
||||
t.Fatalf("group admin text command should expose admin action buttons: %#v", reply.Buttons)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,12 +25,12 @@ func (s *TelegramBotService) telegramCommandDefinitions(ctx context.Context, cha
|
||||
return []telegramCommandDefinition{
|
||||
{Aliases: []string{"/start"}, GroupAllowed: true, Handle: func(args []string) (telegramCommandReply, error) {
|
||||
if len(args) == 0 {
|
||||
return s.mainMenu(ctx, channel, telegramPrivateMessageForUser(msg)), nil
|
||||
return s.mainMenu(ctx, channel, msg), nil
|
||||
}
|
||||
return s.cmdStart(ctx, msg, args), nil
|
||||
}},
|
||||
{Aliases: []string{"/menu"}, GroupAllowed: true, Handle: func(args []string) (telegramCommandReply, error) {
|
||||
return s.mainMenu(ctx, channel, telegramPrivateMessageForUser(msg)), nil
|
||||
return s.mainMenu(ctx, channel, msg), nil
|
||||
}},
|
||||
{Aliases: []string{"/cancel"}, GroupAllowed: true, Handle: func(args []string) (telegramCommandReply, error) {
|
||||
s.takePending(int64(msg.From.ID))
|
||||
@@ -176,7 +176,9 @@ func (s *TelegramBotService) executeCommand(ctx context.Context, channel *model.
|
||||
}
|
||||
reply, err := def.Handle(args)
|
||||
if telegramIsGroupChat(msg.Chat.Type) && def.AdminOnly && !def.GroupAllowed {
|
||||
reply.Buttons = nil
|
||||
if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) {
|
||||
reply.Buttons = nil
|
||||
}
|
||||
}
|
||||
return reply, err
|
||||
}
|
||||
@@ -273,6 +275,7 @@ func registerTelegramBotCommands(ctx context.Context, cfg map[string]string) err
|
||||
if err := telegramSetBotCommands(ctx, cfg, telegramGroupBotCommandMenu(), map[string]interface{}{"type": "all_group_chats"}); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = telegramSetBotCommands(ctx, cfg, telegramAdminBotCommandMenu(), map[string]interface{}{"type": "all_chat_administrators"})
|
||||
|
||||
adminCommands := telegramAdminBotCommandMenu()
|
||||
for _, adminID := range telegramConfiguredUserIDs(cfg["admin_user_ids"]) {
|
||||
|
||||
@@ -80,7 +80,19 @@ func (s *TelegramBotService) mainMenu(ctx context.Context, channel *model.Notify
|
||||
)
|
||||
}
|
||||
if isAdmin {
|
||||
header += "\n\n" + telegramGroupPrivateAdminHint()
|
||||
header += "\n\n<b>管理员入口</b>"
|
||||
rows = append(rows,
|
||||
[]telegramInlineButton{{Text: "—— 管理员 ——", Data: "noop"}},
|
||||
[]telegramInlineButton{
|
||||
{Text: "📊 容量/状态", Data: "adm_capacity"},
|
||||
{Text: "👥 用户管理", Data: "adm_users"},
|
||||
},
|
||||
[]telegramInlineButton{
|
||||
{Text: "🔓 开注设置", Data: "adm_openreg"},
|
||||
{Text: "🎟 生成兑换码", Data: "adm_gencode"},
|
||||
},
|
||||
[]telegramInlineButton{{Text: "⚙️ 设备策略", Data: "adm_devicepolicy"}},
|
||||
)
|
||||
}
|
||||
return telegramCommandReply{Text: header, Buttons: rows}
|
||||
}
|
||||
@@ -190,10 +202,7 @@ func (s *TelegramBotService) handleMenuCallback(ctx context.Context, channel *mo
|
||||
}
|
||||
|
||||
// ── 管理员专属 ──
|
||||
if isGroup {
|
||||
if isAdmin {
|
||||
return telegramCommandReply{Text: telegramGroupPrivateAdminHint()}, true
|
||||
}
|
||||
if isGroup && !isAdmin {
|
||||
return telegramCommandReply{}, true
|
||||
}
|
||||
if !isAdmin {
|
||||
@@ -557,7 +566,7 @@ func (s *TelegramBotService) createUserFromRegistrationCode(ctx context.Context,
|
||||
var created model.User
|
||||
var claimed model.RegistrationCode
|
||||
err := s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("code = ? AND kind = ? AND used_at IS NULL", code, model.RegistrationCodeRegister).
|
||||
if err := tx.Where("code = ? AND kind = ? AND used_at IS NULL AND used_count < CASE WHEN max_uses > 0 THEN max_uses ELSE 1 END", code, model.RegistrationCodeRegister).
|
||||
First(&claimed).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errRegistrationCodeAlreadyUsed
|
||||
@@ -598,8 +607,12 @@ func (s *TelegramBotService) createUserFromRegistrationCode(ctx context.Context,
|
||||
}
|
||||
now := time.Now()
|
||||
res := tx.Model(&model.RegistrationCode{}).
|
||||
Where("id = ? AND used_at IS NULL", claimed.ID).
|
||||
Updates(map[string]any{"used_by_user_id": created.ID, "used_at": &now})
|
||||
Where("id = ? AND used_at IS NULL AND used_count < CASE WHEN max_uses > 0 THEN max_uses ELSE 1 END", claimed.ID).
|
||||
Updates(map[string]any{
|
||||
"used_by_user_id": created.ID,
|
||||
"used_count": gorm.Expr("used_count + 1"),
|
||||
"used_at": gorm.Expr("CASE WHEN used_count + 1 >= CASE WHEN max_uses > 0 THEN max_uses ELSE 1 END THEN ? ELSE used_at END", now),
|
||||
})
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
@@ -607,7 +620,10 @@ func (s *TelegramBotService) createUserFromRegistrationCode(ctx context.Context,
|
||||
return errRegistrationCodeAlreadyUsed
|
||||
}
|
||||
claimed.UsedByUserID = created.ID
|
||||
claimed.UsedAt = &now
|
||||
claimed.UsedCount++
|
||||
if claimed.UsedCount >= claimed.EffectiveMaxUses() {
|
||||
claimed.UsedAt = &now
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
@@ -713,7 +729,7 @@ func (s *TelegramBotService) replyGenCode(ctx context.Context, msg *TelegramMess
|
||||
|
||||
func (s *TelegramBotService) cmdGenCode(ctx context.Context, msg *TelegramMessage, args []string) telegramCommandReply {
|
||||
if len(args) < 2 {
|
||||
return telegramCommandReply{Text: "用法:<code>/gencode register|renew 天数 [有效天数]</code>\n示例:<code>/gencode register 30</code>、<code>/gencode renew 90 7</code>"}
|
||||
return telegramCommandReply{Text: "用法:<code>/gencode register|renew 天数 [有效天数] [可用次数]</code>\n示例:<code>/gencode register 30</code>、<code>/gencode renew 90 7 5</code>"}
|
||||
}
|
||||
kind := strings.ToLower(strings.TrimSpace(args[0]))
|
||||
switch kind {
|
||||
@@ -735,11 +751,18 @@ func (s *TelegramBotService) cmdGenCode(ctx context.Context, msg *TelegramMessag
|
||||
return telegramCommandReply{Text: "有效天数必须是非负整数。"}
|
||||
}
|
||||
}
|
||||
maxUses := 1
|
||||
if len(args) > 3 {
|
||||
maxUses, err = strconv.Atoi(args[3])
|
||||
if err != nil || maxUses <= 0 {
|
||||
return telegramCommandReply{Text: "可用次数必须是正整数。"}
|
||||
}
|
||||
}
|
||||
createdBy := ""
|
||||
if u := s.boundUser(ctx, msg.From.ID); u != nil {
|
||||
createdBy = u.ID
|
||||
}
|
||||
code, err := s.generateCode(ctx, kind, days, validDays, createdBy)
|
||||
code, err := s.generateCodeWithUses(ctx, kind, days, validDays, maxUses, createdBy)
|
||||
if err != nil {
|
||||
return telegramCommandReply{Text: "生成失败:" + err.Error()}
|
||||
}
|
||||
@@ -752,7 +775,11 @@ func (s *TelegramBotService) cmdGenCode(ctx context.Context, msg *TelegramMessag
|
||||
if validDays > 0 && code.ExpiresAt != nil {
|
||||
valid = "有效至 " + code.ExpiresAt.Format("2006-01-02 15:04")
|
||||
}
|
||||
return telegramCommandReply{Text: fmt.Sprintf("已生成%s(%s,%s):\n\n<code>%s</code>", kindLabel, dur, valid, code.Code)}
|
||||
uses := "单次使用"
|
||||
if code.EffectiveMaxUses() > 1 {
|
||||
uses = fmt.Sprintf("最多 %d 次", code.EffectiveMaxUses())
|
||||
}
|
||||
return telegramCommandReply{Text: fmt.Sprintf("已生成%s(%s,%s,%s):\n\n<code>%s</code>", kindLabel, dur, valid, uses, code.Code)}
|
||||
}
|
||||
|
||||
func (s *TelegramBotService) replyUserList(ctx context.Context) telegramCommandReply {
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#faf8f5" />
|
||||
<meta name="description" content="MediaStationGo — 个人家庭媒体中心" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/png" href="/brand/mgo-emby-icon.png" />
|
||||
<link rel="apple-touch-icon" href="/brand/mgo-emby-icon.png" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
|
||||
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,20 @@
|
||||
<svg width="920" height="160" viewBox="0 0 460 80" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Logo Mark -->
|
||||
<rect x="10" y="15" width="50" height="50" rx="14" fill="url(#horizonPurpleGrad)" />
|
||||
<path d="M26 30 L43 40 L26 50" stroke="white" stroke-width="4" stroke-linecap="square" stroke-linejoin="miter" fill="none" />
|
||||
<path d="M26 43 L26 54 L34 48" stroke="white" stroke-width="4" stroke-linecap="square" stroke-linejoin="miter" fill="none" />
|
||||
<circle cx="46" cy="28" r="3.5" fill="#06B6D4" />
|
||||
|
||||
<!-- Typography -->
|
||||
<text x="75" y="47" font-family="'Plus Jakarta Sans', system-ui, sans-serif" font-weight="800" font-size="28" fill="#111827" letter-spacing="-0.5">Mgo-Emby</text>
|
||||
<text x="238" y="47" font-family="'Plus Jakarta Sans', system-ui, sans-serif" font-weight="300" font-size="28" fill="#9CA3AF" letter-spacing="-0.5"> 社区</text>
|
||||
<rect x="315" y="26" width="48" height="22" rx="6" fill="#06B6D4" fill-opacity="0.1" stroke="#06B6D4" stroke-width="1" />
|
||||
<text x="339" y="41" font-family="'JetBrains Mono', monospace" font-weight="700" font-size="11" fill="#06B6D4" text-anchor="middle">BBS</text>
|
||||
|
||||
<defs>
|
||||
<linearGradient id="horizonPurpleGrad" x1="10" y1="15" x2="60" y2="65" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#7C3AED" />
|
||||
<stop offset="1" stop-color="#5B21B6" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,12 @@
|
||||
<svg width="64" height="64" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="32" height="32" rx="8" fill="url(#mgoPurpleGrad)" />
|
||||
<path d="M11 10 L21 16 L11 22" stroke="white" stroke-width="2.5" stroke-linecap="square" stroke-linejoin="miter" fill="none" />
|
||||
<path d="M11 18 L11 24 L16 21" stroke="white" stroke-width="2.5" stroke-linecap="square" stroke-linejoin="miter" fill="none" />
|
||||
<circle cx="23" cy="9" r="2" fill="#06B6D4" />
|
||||
<defs>
|
||||
<linearGradient id="mgoPurpleGrad" x1="0" y1="0" x2="32" y2="32" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#7C3AED" />
|
||||
<stop offset="1" stop-color="#5B21B6" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 705 B |
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg width="200" height="200" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="5" y="5" width="90" height="90" rx="24" stroke="currentColor" stroke-width="6" />
|
||||
<path d="M35 30 L65 47 L35 64" stroke="currentColor" stroke-width="8" stroke-linecap="square" stroke-linejoin="miter" fill="none" />
|
||||
<path d="M35 51 L35 72 L48 64" stroke="currentColor" stroke-width="8" stroke-linecap="square" stroke-linejoin="miter" fill="none" />
|
||||
<circle cx="69" cy="29" r="6" fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 525 B |
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,16 @@
|
||||
<svg width="400" height="480" viewBox="0 0 200 240" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="40" y="20" width="120" height="120" rx="32" fill="url(#primaryPurpleGrad)" />
|
||||
<path d="M80 55 L120 80 L80 105" stroke="white" stroke-width="9" stroke-linecap="square" stroke-linejoin="miter" fill="none" />
|
||||
<path d="M80 87 L80 114 L100 101" stroke="white" stroke-width="9" stroke-linecap="square" stroke-linejoin="miter" fill="none" />
|
||||
<circle cx="128" cy="52" r="8" fill="#06B6D4" />
|
||||
|
||||
<text x="100" y="185" font-family="'Plus Jakarta Sans', system-ui, sans-serif" font-weight="800" font-size="24" fill="#111827" text-anchor="middle" letter-spacing="1">Mgo-Emby</text>
|
||||
<text x="100" y="215" font-family="'Inter', system-ui, sans-serif" font-weight="500" font-size="14" fill="#06B6D4" text-anchor="middle" letter-spacing="4">社区</text>
|
||||
|
||||
<defs>
|
||||
<linearGradient id="primaryPurpleGrad" x1="40" y1="20" x2="160" y2="140" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#7C3AED" />
|
||||
<stop offset="1" stop-color="#5B21B6" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
@@ -0,0 +1,20 @@
|
||||
<svg width="460" height="80" viewBox="0 0 460 80" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Logo Mark -->
|
||||
<rect x="10" y="15" width="50" height="50" rx="14" fill="url(#horizonPurpleGrad)" />
|
||||
<path d="M26 30 L43 40 L26 50" stroke="white" stroke-width="4" stroke-linecap="square" stroke-linejoin="miter" fill="none" />
|
||||
<path d="M26 43 L26 54 L34 48" stroke="white" stroke-width="4" stroke-linecap="square" stroke-linejoin="miter" fill="none" />
|
||||
<circle cx="46" cy="28" r="3.5" fill="#06B6D4" />
|
||||
|
||||
<!-- Typography -->
|
||||
<text x="75" y="47" font-family="'Plus Jakarta Sans', system-ui, sans-serif" font-weight="800" font-size="28" fill="#111827" letter-spacing="-0.5">Mgo-Emby</text>
|
||||
<text x="238" y="47" font-family="'Plus Jakarta Sans', system-ui, sans-serif" font-weight="300" font-size="28" fill="#9CA3AF" letter-spacing="-0.5"> 社区</text>
|
||||
<rect x="315" y="26" width="48" height="22" rx="6" fill="#06B6D4" fill-opacity="0.1" stroke="#06B6D4" stroke-width="1" />
|
||||
<text x="339" y="41" font-family="'JetBrains Mono', monospace" font-weight="700" font-size="11" fill="#06B6D4" text-anchor="middle">BBS</text>
|
||||
|
||||
<defs>
|
||||
<linearGradient id="horizonPurpleGrad" x1="10" y1="15" x2="60" y2="65" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#7C3AED" />
|
||||
<stop offset="1" stop-color="#5B21B6" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 981 B |
@@ -0,0 +1,12 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="32" height="32" rx="8" fill="url(#mgoPurpleGrad)" />
|
||||
<path d="M11 10 L21 16 L11 22" stroke="white" stroke-width="2.5" stroke-linecap="square" stroke-linejoin="miter" fill="none" />
|
||||
<path d="M11 18 L11 24 L16 21" stroke="white" stroke-width="2.5" stroke-linecap="square" stroke-linejoin="miter" fill="none" />
|
||||
<circle cx="23" cy="9" r="2" fill="#06B6D4" />
|
||||
<defs>
|
||||
<linearGradient id="mgoPurpleGrad" x1="0" y1="0" x2="32" y2="32" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#7C3AED" />
|
||||
<stop offset="1" stop-color="#5B21B6" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 705 B |
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg width="100" height="100" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="5" y="5" width="90" height="90" rx="24" stroke="currentColor" stroke-width="6" />
|
||||
<path d="M35 30 L65 47 L35 64" stroke="currentColor" stroke-width="8" stroke-linecap="square" stroke-linejoin="miter" fill="none" />
|
||||
<path d="M35 51 L35 72 L48 64" stroke="currentColor" stroke-width="8" stroke-linecap="square" stroke-linejoin="miter" fill="none" />
|
||||
<circle cx="69" cy="29" r="6" fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 525 B |
|
After Width: | Height: | Size: 6.6 KiB |
@@ -0,0 +1,16 @@
|
||||
<svg width="200" height="240" viewBox="0 0 200 240" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="40" y="20" width="120" height="120" rx="32" fill="url(#primaryPurpleGrad)" />
|
||||
<path d="M80 55 L120 80 L80 105" stroke="white" stroke-width="9" stroke-linecap="square" stroke-linejoin="miter" fill="none" />
|
||||
<path d="M80 87 L80 114 L100 101" stroke="white" stroke-width="9" stroke-linecap="square" stroke-linejoin="miter" fill="none" />
|
||||
<circle cx="128" cy="52" r="8" fill="#06B6D4" />
|
||||
|
||||
<text x="100" y="185" font-family="'Plus Jakarta Sans', system-ui, sans-serif" font-weight="800" font-size="24" fill="#111827" text-anchor="middle" letter-spacing="1">Mgo-Emby</text>
|
||||
<text x="100" y="215" font-family="'Inter', system-ui, sans-serif" font-weight="500" font-size="14" fill="#06B6D4" text-anchor="middle" letter-spacing="4">社区</text>
|
||||
|
||||
<defs>
|
||||
<linearGradient id="primaryPurpleGrad" x1="40" y1="20" x2="160" y2="140" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#7C3AED" />
|
||||
<stop offset="1" stop-color="#5B21B6" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -1,17 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="MediaStationGo">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="10" y1="6" x2="54" y2="58" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#7c3aed"/>
|
||||
<stop offset="0.48" stop-color="#2563eb"/>
|
||||
<stop offset="1" stop-color="#06b6d4"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="play" x1="23" y1="18" x2="45" y2="46" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#fff7ed"/>
|
||||
<stop offset="1" stop-color="#ffffff"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="64" height="64" rx="18" fill="url(#bg)"/>
|
||||
<path d="M18 21.5A7.5 7.5 0 0 1 25.5 14h13A7.5 7.5 0 0 1 46 21.5v21A7.5 7.5 0 0 1 38.5 50h-13A7.5 7.5 0 0 1 18 42.5v-21Z" fill="#0f172a" opacity=".22"/>
|
||||
<path d="M22 22.9c0-3.05 3.32-4.95 5.96-3.42l17.08 9.86c2.64 1.52 2.64 5.34 0 6.86L27.96 46.06C25.32 47.58 22 45.68 22 42.63V22.9Z" fill="url(#play)"/>
|
||||
<path d="M13 18h6M13 28h6M13 38h6M45 18h6M45 28h6M45 38h6" stroke="#ffffff" stroke-width="3" stroke-linecap="round" opacity=".72"/>
|
||||
<image href="/brand/mgo-emby-icon.png" width="64" height="64" preserveAspectRatio="xMidYMid meet"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 209 B |
@@ -113,7 +113,7 @@ class AppErrorBoundary extends Component<{ children: ReactNode }, { hasError: bo
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.error('MediaStation UI crashed', error, info)
|
||||
console.error('MediaStationGo UI crashed', error, info)
|
||||
}
|
||||
|
||||
render() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { api } from './client'
|
||||
|
||||
// ── License Server types (generic, ready for MediaStationLicenseServer integration) ──
|
||||
// ── License Server types (generic, ready for MediaStationGo license integration) ──
|
||||
|
||||
/** Response after binding/activating a license. */
|
||||
export interface LicenseActivation {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router-do
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import toast from 'react-hot-toast'
|
||||
import {
|
||||
Activity, Bell, Clock, CloudDownload, Compass, Film,
|
||||
Activity, Bell, Clock, CloudDownload, Compass,
|
||||
Cast, Globe, HardDrive, Heart, Home, Image, KeySquare,
|
||||
ListMusic, LogOut, Rss, Search, Trash2,
|
||||
Settings, Sliders, Sparkles, UserCog,
|
||||
@@ -139,16 +139,18 @@ export function Layout() {
|
||||
{/* Brand Logo & Brand Title */}
|
||||
<div className="flex h-20 items-center justify-between px-6 border-b border-gray-100">
|
||||
<Link to="/" className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-[#111827] to-[#1f2937] shadow-sm">
|
||||
<Film className="h-5 w-5 text-[#c9954a]" />
|
||||
</div>
|
||||
<img
|
||||
src="/brand/mgo-emby-icon.png"
|
||||
alt="MediaStationGo"
|
||||
className="h-10 w-10 shrink-0 rounded-xl object-contain shadow-sm"
|
||||
/>
|
||||
{(isSidebarOpen || isMobileDrawerOpen) && (
|
||||
<motion.span
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="font-display text-lg font-extrabold tracking-tight text-[#111827]"
|
||||
>
|
||||
MediaStation
|
||||
MediaStationGo
|
||||
</motion.span>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import toast from 'react-hot-toast'
|
||||
import { Eye, EyeOff, Film, ArrowRight, ShieldCheck, Lock, User } from 'lucide-react'
|
||||
import { Eye, EyeOff, ArrowRight, ShieldCheck, Lock, User } from 'lucide-react'
|
||||
import { AppFooter } from '../components/AppFooter'
|
||||
import { authAPI } from '../api/auth'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
@@ -50,14 +50,14 @@ export function LoginPage() {
|
||||
|
||||
{/* Logo & Headline */}
|
||||
<div className="flex flex-col items-center text-center pb-8">
|
||||
<motion.div
|
||||
<motion.img
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ delay: 0.15, type: 'spring', stiffness: 200 }}
|
||||
className="mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-[#111827] shadow-sm"
|
||||
>
|
||||
<Film className="h-7 w-7 text-[#c9954a]" />
|
||||
</motion.div>
|
||||
src="/brand/mgo-emby-icon.png"
|
||||
alt="MediaStationGo"
|
||||
className="mb-4 h-14 w-14 rounded-2xl object-contain shadow-sm"
|
||||
/>
|
||||
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
@@ -65,7 +65,7 @@ export function LoginPage() {
|
||||
transition={{ delay: 0.25 }}
|
||||
className="font-display text-2xl font-extrabold tracking-tight text-gray-900"
|
||||
>
|
||||
MediaStation
|
||||
MediaStationGo
|
||||
</motion.h1>
|
||||
|
||||
<motion.p
|
||||
@@ -195,4 +195,4 @@ export function LoginPage() {
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +129,14 @@ const TYPE_LABELS: Record<NotifyChannel['type'], string> = {
|
||||
email: 'Email',
|
||||
}
|
||||
|
||||
const EVENT_OPTIONS = [
|
||||
{ value: 'subscription_hit', label: '订阅命中新资源' },
|
||||
{ value: 'download_complete', label: '下载任务完成' },
|
||||
{ value: 'library_ingest', label: '入库完成' },
|
||||
{ value: 'scrape_failed', label: '刮削失败告警' },
|
||||
{ value: 'system_alert', label: '系统异常通知' },
|
||||
]
|
||||
|
||||
function ChannelCard({
|
||||
channel,
|
||||
onTest,
|
||||
@@ -154,6 +162,7 @@ function ChannelCard({
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 truncate text-xs text-ink-50">{summary}</div>
|
||||
<div className="mt-1 truncate text-xs text-sand-500">{eventSummary(channel.events)}</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<button
|
||||
@@ -179,6 +188,12 @@ function ChannelCard({
|
||||
)
|
||||
}
|
||||
|
||||
function eventSummary(events: string[] | undefined): string {
|
||||
if (!events || events.length === 0) return '事件:全部'
|
||||
const labels = events.map((event) => EVENT_OPTIONS.find((item) => item.value === event)?.label ?? event)
|
||||
return `事件:${labels.join('、')}`
|
||||
}
|
||||
|
||||
function channelSummary(ch: NotifyChannel): string {
|
||||
const cfg = ch.config ?? {}
|
||||
switch (ch.type) {
|
||||
@@ -245,6 +260,7 @@ function ChannelFormModal({
|
||||
const [config, setConfig] = useState<Record<string, string>>(
|
||||
normalizeInitialConfig(editing?.type ?? 'telegram', editing?.config ?? {}),
|
||||
)
|
||||
const [events, setEvents] = useState<string[]>(editing?.events ?? [])
|
||||
const [enabled, setEnabled] = useState(editing?.enabled ?? true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
@@ -275,6 +291,7 @@ function ChannelFormModal({
|
||||
name: name.trim(),
|
||||
type: type,
|
||||
config: cleanedConfig,
|
||||
events,
|
||||
enabled,
|
||||
}
|
||||
if (editing) {
|
||||
@@ -296,6 +313,12 @@ function ChannelFormModal({
|
||||
const updateConfig = (k: string, v: string) =>
|
||||
setConfig((c) => ({ ...c, [k]: v }))
|
||||
|
||||
const toggleEvent = (event: string) => {
|
||||
setEvents((current) =>
|
||||
current.includes(event) ? current.filter((item) => item !== event) : [...current, event],
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
|
||||
<div className="glass-panel w-full max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
@@ -532,6 +555,33 @@ function ChannelFormModal({
|
||||
</>
|
||||
)}
|
||||
|
||||
<Field label="推送事件">
|
||||
<div className="space-y-2 rounded-lg border border-gray-200 bg-gray-50/60 p-3">
|
||||
<label className="flex cursor-pointer items-center gap-2 text-sm text-ink-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 accent-primary-400"
|
||||
checked={events.length === 0}
|
||||
onChange={() => setEvents([])}
|
||||
/>
|
||||
全部事件
|
||||
</label>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{EVENT_OPTIONS.map((event) => (
|
||||
<label key={event.value} className="flex cursor-pointer items-center gap-2 text-sm text-ink-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 accent-primary-400"
|
||||
checked={events.includes(event.value)}
|
||||
onChange={() => toggleEvent(event.value)}
|
||||
/>
|
||||
{event.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<label className="flex cursor-pointer items-center gap-2 text-sm text-ink-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
||||
@@ -140,7 +140,7 @@ const GROUPS: SettingGroup[] = [
|
||||
{
|
||||
key: 'license',
|
||||
label: '授权服务',
|
||||
description: '连接私有 MediaStationLicenseServer;开源版默认最多 20 个用户,激活后按授权策略提升额度。',
|
||||
description: '连接私有 MediaStationGo 授权服务;开源版默认最多 20 个用户,激活后按授权策略提升额度。',
|
||||
items: [
|
||||
{
|
||||
key: 'license.server_url',
|
||||
|
||||
@@ -312,7 +312,7 @@ export interface NotifyChannel {
|
||||
name: string
|
||||
type: 'telegram' | 'wechat' | 'bark' | 'webhook' | 'email'
|
||||
enabled: boolean
|
||||
events: string
|
||||
events: string[]
|
||||
config: Record<string, string>
|
||||
created_at: string
|
||||
updated_at: string
|
||||
|
||||