From ab4637beed09b4c693622f4904de24b378f45e3d Mon Sep 17 00:00:00 2001 From: ShukeBta <272197458+ShukeBta@users.noreply.github.com> Date: Wed, 17 Jun 2026 16:01:16 +0800 Subject: [PATCH] Update MediaStationGo branding and deployment docs --- README.md | 628 ++++++------------ README_EN.md | 2 +- cmd/server/main.go | 2 +- config.example.yaml | 2 +- docker-compose.advanced.yml | 7 +- docker-compose.yml | 7 +- docs/prd-mediastation-full-features.md | 12 +- docs/refactor-architecture.md | 2 +- internal/config/config.go | 2 +- internal/handler/emby.go | 2 +- internal/handler/handler.go | 8 +- internal/handler/media.go | 32 +- internal/handler/routes_authenticated.go | 2 +- internal/model/bot.go | 23 +- internal/repository/bot_repo.go | 16 +- internal/service/api_config.go | 4 +- internal/service/auth.go | 2 +- internal/service/bot_features.go | 8 + internal/service/bot_features_test.go | 57 ++ internal/service/downloads.go | 25 + internal/service/emby_compat.go | 209 +++++- internal/service/emby_compat_test.go | 80 ++- internal/service/notify_svc.go | 3 +- internal/service/scanner.go | 63 +- internal/service/scraper.go | 24 + internal/service/service.go | 4 + internal/service/site.go | 34 +- internal/service/site_adapter.go | 2 +- internal/service/site_adapter_mteam.go | 8 +- internal/service/subscription.go | 22 + internal/service/subtitle.go | 2 +- internal/service/telegram_api_test.go | 21 +- internal/service/telegram_bot.go | 15 +- internal/service/telegram_bot_user_test.go | 31 +- internal/service/telegram_commands.go | 9 +- internal/service/telegram_menu.go | 51 +- web/index.html | 3 +- web/public/brand/mgo-emby-horizontal.png | Bin 0 -> 13811 bytes web/public/brand/mgo-emby-horizontal.svg | 20 + web/public/brand/mgo-emby-icon-large-logo.png | Bin 0 -> 20391 bytes web/public/brand/mgo-emby-icon.png | Bin 0 -> 1812 bytes web/public/brand/mgo-emby-icon.svg | 12 + web/public/brand/mgo-emby-monochrome.png | Bin 0 -> 3874 bytes web/public/brand/mgo-emby-monochrome.svg | 6 + web/public/brand/mgo-emby-stacked.png | Bin 0 -> 14452 bytes web/public/brand/mgo-emby-stacked.svg | 16 + .../original-size/mgo-emby-horizontal.png | Bin 0 -> 6153 bytes .../original-size/mgo-emby-horizontal.svg | 20 + .../brand/original-size/mgo-emby-icon.png | Bin 0 -> 981 bytes .../brand/original-size/mgo-emby-icon.svg | 12 + .../original-size/mgo-emby-monochrome.png | Bin 0 -> 1824 bytes .../original-size/mgo-emby-monochrome.svg | 6 + .../brand/original-size/mgo-emby-stacked.png | Bin 0 -> 6795 bytes .../brand/original-size/mgo-emby-stacked.svg | 16 + web/public/favicon.svg | 16 +- web/src/App.tsx | 2 +- web/src/api/license.ts | 2 +- web/src/components/Layout.tsx | 12 +- web/src/pages/LoginPage.tsx | 16 +- web/src/pages/NotifyChannelsPage.tsx | 50 ++ web/src/pages/SettingsPage.tsx | 2 +- web/src/types/index.ts | 2 +- 62 files changed, 1035 insertions(+), 599 deletions(-) create mode 100644 web/public/brand/mgo-emby-horizontal.png create mode 100644 web/public/brand/mgo-emby-horizontal.svg create mode 100644 web/public/brand/mgo-emby-icon-large-logo.png create mode 100644 web/public/brand/mgo-emby-icon.png create mode 100644 web/public/brand/mgo-emby-icon.svg create mode 100644 web/public/brand/mgo-emby-monochrome.png create mode 100644 web/public/brand/mgo-emby-monochrome.svg create mode 100644 web/public/brand/mgo-emby-stacked.png create mode 100644 web/public/brand/mgo-emby-stacked.svg create mode 100644 web/public/brand/original-size/mgo-emby-horizontal.png create mode 100644 web/public/brand/original-size/mgo-emby-horizontal.svg create mode 100644 web/public/brand/original-size/mgo-emby-icon.png create mode 100644 web/public/brand/original-size/mgo-emby-icon.svg create mode 100644 web/public/brand/original-size/mgo-emby-monochrome.png create mode 100644 web/public/brand/original-size/mgo-emby-monochrome.svg create mode 100644 web/public/brand/original-size/mgo-emby-stacked.png create mode 100644 web/public/brand/original-size/mgo-emby-stacked.svg diff --git a/README.md b/README.md index 2eacb6b..6df0beb 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,21 @@ # MediaStationGo
-
+
- Docker 一键部署 · 多用户管理 · 媒体库 · 刮削 · 下载整理 · Emby 协议兼容 · 网盘播放 + Docker 一键部署 · PostgreSQL 主库 · Redis 热缓存 · OpenSearch 搜索增强 · Emby 协议兼容 · Bot 通知
- English · 快速开始 · - Docker 部署 · - 常见问题 · - 在线演示 + 三挡部署 · + 路径映射 · + 旧 SQLite 迁移 · + 开发构建
@@ -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 搜索、推荐、助手 |
-
----
-
-## 截图
-
- Made with ❤️ by ShukeBta
- 界面预览
-
-| 登录 | 首页 |
-| --- | --- |
-|
|
|
-
-| 媒体库 | 播放器 |
-| --- | --- |
-|
|
|
-
-
-
----
-
-## Star History
-
-
-
-
+
A lightweight, polished, NAS-friendly private media center
diff --git a/cmd/server/main.go b/cmd/server/main.go
index 3d626db..d4bc57d 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -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
diff --git a/config.example.yaml b/config.example.yaml
index 8a4e3b6..be9394f 100644
--- a/config.example.yaml
+++ b/config.example.yaml
@@ -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
diff --git a/docker-compose.advanced.yml b/docker-compose.advanced.yml
index 1a11466..d377490 100644
--- a/docker-compose.advanced.yml
+++ b/docker-compose.advanced.yml
@@ -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
diff --git a/docker-compose.yml b/docker-compose.yml
index ab4b3a8..5ca3851 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -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
diff --git a/docs/prd-mediastation-full-features.md b/docs/prd-mediastation-full-features.md
index 9b25364..6955cc1 100644
--- a/docs/prd-mediastation-full-features.md
+++ b/docs/prd-mediastation-full-features.md
@@ -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 全量源代码
diff --git a/docs/refactor-architecture.md b/docs/refactor-architecture.md
index 67bd76c..f3564e6 100644
--- a/docs/refactor-architecture.md
+++ b/docs/refactor-architecture.md
@@ -2,7 +2,7 @@
> **版本**: v1.0 | **日期**: 2026-02-04 | **作者**: Architect (Bob)
>
-> 本文档基于原版 MediaStation(~220 API)与当前 Go 版实现(~50+ API)的差距分析,
+> 本文档基于旧版 Python 实现(~220 API)与当前 Go 版实现(~50+ API)的差距分析,
> 设计完整的重构架构方案,涵盖数据模型、文件结构、依赖、任务分解和跨模块约定。
---
diff --git a/internal/config/config.go b/internal/config/config.go
index a54be88..db90a9e 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -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"`
diff --git a/internal/handler/emby.go b/internal/handler/emby.go
index f3eb9a4..bc34ddd 100644
--- a/internal/handler/emby.go
+++ b/internal/handler/emby.go
@@ -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,
diff --git a/internal/handler/handler.go b/internal/handler/handler.go
index 68ae5c5..5e1acdf 100644
--- a/internal/handler/handler.go
+++ b/internal/handler/handler.go
@@ -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 {
}
}
}
-
-
-
-
diff --git a/internal/handler/media.go b/internal/handler/media.go
index 50c2eaf..f51a9df 100644
--- a/internal/handler/media.go
+++ b/internal/handler/media.go
@@ -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": "可在右上角任务面板查看扫描进度",
+ })
}
}
diff --git a/internal/handler/routes_authenticated.go b/internal/handler/routes_authenticated.go
index f615b98..79b0885 100644
--- a/internal/handler/routes_authenticated.go
+++ b/internal/handler/routes_authenticated.go
@@ -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))
diff --git a/internal/model/bot.go b/internal/model/bot.go
index 7fe3453..9bf345f 100644
--- a/internal/model/bot.go
+++ b/internal/model/bot.go
@@ -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 {
diff --git a/internal/repository/bot_repo.go b/internal/repository/bot_repo.go
index 13900f0..e1e24b9 100644
--- a/internal/repository/bot_repo.go
+++ b/internal/repository/bot_repo.go
@@ -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
}
diff --git a/internal/service/api_config.go b/internal/service/api_config.go
index 8fe2f5c..a2ccaab 100644
--- a/internal/service/api_config.go
+++ b/internal/service/api_config.go
@@ -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 (
diff --git a/internal/service/auth.go b/internal/service/auth.go
index f9c4435..ae52b8a 100644
--- a/internal/service/auth.go
+++ b/internal/service/auth.go
@@ -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)
diff --git a/internal/service/bot_features.go b/internal/service/bot_features.go
index fcf7741..e39ab65 100644
--- a/internal/service/bot_features.go
+++ b/internal/service/bot_features.go
@@ -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 {
diff --git a/internal/service/bot_features_test.go b/internal/service/bot_features_test.go
index 1044b20..3d114f9 100644
--- a/internal/service/bot_features_test.go
+++ b/internal/service/bot_features_test.go
@@ -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)
diff --git a/internal/service/downloads.go b/internal/service/downloads.go
index c895ffe..237f75f 100644
--- a/internal/service/downloads.go
+++ b/internal/service/downloads.go
@@ -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 == "" {
diff --git a/internal/service/emby_compat.go b/internal/service/emby_compat.go
index 0e952af..355fd5a 100644
--- a/internal/service/emby_compat.go
+++ b/internal/service/emby_compat.go
@@ -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))
}
diff --git a/internal/service/emby_compat_test.go b/internal/service/emby_compat_test.go
index 19dd412..5f03d78 100644
--- a/internal/service/emby_compat_test.go
+++ b/internal/service/emby_compat_test.go
@@ -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) {
diff --git a/internal/service/notify_svc.go b/internal/service/notify_svc.go
index 949ebb6..8aecc41 100644
--- a/internal/service/notify_svc.go
+++ b/internal/service/notify_svc.go
@@ -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
}
diff --git a/internal/service/scanner.go b/internal/service/scanner.go
index cac6df8..813d7f5 100644
--- a/internal/service/scanner.go
+++ b/internal/service/scanner.go
@@ -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 (减少硬盘损耗).
diff --git a/internal/service/scraper.go b/internal/service/scraper.go
index bad7383..d19acd3 100644
--- a/internal/service/scraper.go
+++ b/internal/service/scraper.go
@@ -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)
diff --git a/internal/service/service.go b/internal/service/service.go
index 7a1a58a..7e2ea62 100644
--- a/internal/service/service.go
+++ b/internal/service/service.go
@@ -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 资源就存放在这些(用户自定义、任意)目录下,否则会被
diff --git a/internal/service/site.go b/internal/service/site.go
index 4065831..771c44c 100644
--- a/internal/service/site.go
+++ b/internal/service/site.go
@@ -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——后者通常
diff --git a/internal/service/site_adapter.go b/internal/service/site_adapter.go
index 9f575dc..23a0040 100644
--- a/internal/service/site_adapter.go
+++ b/internal/service/site_adapter.go
@@ -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 != "" {
diff --git a/internal/service/site_adapter_mteam.go b/internal/service/site_adapter_mteam.go
index f6a052c..7414baf 100644
--- a/internal/service/site_adapter_mteam.go
+++ b/internal/service/site_adapter_mteam.go
@@ -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" 表示成功
diff --git a/internal/service/subscription.go b/internal/service/subscription.go
index d06f12c..0923d90 100644
--- a/internal/service/subscription.go
+++ b/internal/service/subscription.go
@@ -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
diff --git a/internal/service/subtitle.go b/internal/service/subtitle.go
index 07ffe58..b4c2dc0 100644
--- a/internal/service/subtitle.go
+++ b/internal/service/subtitle.go
@@ -4,7 +4,7 @@
// converts SRT to WebVTT on the fly so the browser