diff --git a/README.md b/README.md index 2eacb6b..6df0beb 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,21 @@ # MediaStationGo

- MediaStationGo Logo + MediaStationGo Logo

-

轻量、好看、适合 NAS 的私人媒体中心

+

适合 NAS、家庭共享和多端播放的私人媒体中心

- 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 搜索、推荐、助手 | - ---- - -## 截图 - -

-界面预览 - -| 登录 | 首页 | -| --- | --- | -| 登录 | 首页 | - -| 媒体库 | 播放器 | -| --- | --- | -| 媒体库 | 播放器 | - -
- ---- - -## 开发者运行 - -普通用户请优先使用 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交流群: -- 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 +``` -## 赞赏 +## 许可证 -如果这个项目节省了你的时间,欢迎请作者吃桶泡面。 - -微信赞赏码 - ---- - -## Star History - - - - - - Star History Chart - - - ---- - -## 许可证与非商用声明 - -本项目基础许可证遵循 `GPL-3.0`,详见 [LICENSE](LICENSE)。 - -项目维护者同时声明并倡议: - -- 本项目主要面向个人学习、家庭 NAS、自建影音、非商业研究与社区共建场景。 -- 未经作者明确书面许可,不得将本项目或衍生版本用于商业售卖、商业托管、付费 SaaS、预装售卖设备、闭源二次分发或其他商业化牟利用途。 -- 如需商业合作、企业内部部署、定制开发、集成发行或商业授权,请先联系作者确认授权边界。 -- 若 README 的非商用声明与 `GPL-3.0` 正式许可文本存在解释差异,代码授权以 [LICENSE](LICENSE) 文件为准,商业使用请额外取得作者许可。 - ---- - -

Made with ❤️ by ShukeBta

+本项目使用 GPL-3.0 License。详见 [LICENSE](LICENSE)。 diff --git a/README_EN.md b/README_EN.md index 720d851..cbd849f 100644 --- a/README_EN.md +++ b/README_EN.md @@ -1,7 +1,7 @@ # MediaStationGo

- MediaStationGo Logo + MediaStationGo Logo

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 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. diff --git a/internal/service/telegram_api_test.go b/internal/service/telegram_api_test.go index ed0fafa..9fd52cf 100644 --- a/internal/service/telegram_api_test.go +++ b/internal/service/telegram_api_test.go @@ -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]) } } diff --git a/internal/service/telegram_bot.go b/internal/service/telegram_bot.go index b519945..17cd4a3 100644 --- a/internal/service/telegram_bot.go +++ b/internal/service/telegram_bot.go @@ -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) { diff --git a/internal/service/telegram_bot_user_test.go b/internal/service/telegram_bot_user_test.go index e9835df..607fba7 100644 --- a/internal/service/telegram_bot_user_test.go +++ b/internal/service/telegram_bot_user_test.go @@ -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) } } diff --git a/internal/service/telegram_commands.go b/internal/service/telegram_commands.go index cd8da51..d6efaab 100644 --- a/internal/service/telegram_commands.go +++ b/internal/service/telegram_commands.go @@ -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"]) { diff --git a/internal/service/telegram_menu.go b/internal/service/telegram_menu.go index 00cc06c..192d7fb 100644 --- a/internal/service/telegram_menu.go +++ b/internal/service/telegram_menu.go @@ -80,7 +80,19 @@ func (s *TelegramBotService) mainMenu(ctx context.Context, channel *model.Notify ) } if isAdmin { - header += "\n\n" + telegramGroupPrivateAdminHint() + header += "\n\n管理员入口" + 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: "用法:/gencode register|renew 天数 [有效天数]\n示例:/gencode register 30、/gencode renew 90 7"} + return telegramCommandReply{Text: "用法:/gencode register|renew 天数 [有效天数] [可用次数]\n示例:/gencode register 30、/gencode renew 90 7 5"} } 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%s", 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%s", kindLabel, dur, valid, uses, code.Code)} } func (s *TelegramBotService) replyUserList(ctx context.Context) telegramCommandReply { diff --git a/web/index.html b/web/index.html index c92797c..50e58b9 100644 --- a/web/index.html +++ b/web/index.html @@ -5,7 +5,8 @@ - + + + + + + + + + + Mgo-Emby + 社区 + + BBS + + + + + + + + diff --git a/web/public/brand/mgo-emby-icon-large-logo.png b/web/public/brand/mgo-emby-icon-large-logo.png new file mode 100644 index 0000000..39e5aca Binary files /dev/null and b/web/public/brand/mgo-emby-icon-large-logo.png differ diff --git a/web/public/brand/mgo-emby-icon.png b/web/public/brand/mgo-emby-icon.png new file mode 100644 index 0000000..290546b Binary files /dev/null and b/web/public/brand/mgo-emby-icon.png differ diff --git a/web/public/brand/mgo-emby-icon.svg b/web/public/brand/mgo-emby-icon.svg new file mode 100644 index 0000000..33b8b4e --- /dev/null +++ b/web/public/brand/mgo-emby-icon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/web/public/brand/mgo-emby-monochrome.png b/web/public/brand/mgo-emby-monochrome.png new file mode 100644 index 0000000..507afe7 Binary files /dev/null and b/web/public/brand/mgo-emby-monochrome.png differ diff --git a/web/public/brand/mgo-emby-monochrome.svg b/web/public/brand/mgo-emby-monochrome.svg new file mode 100644 index 0000000..f79b341 --- /dev/null +++ b/web/public/brand/mgo-emby-monochrome.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/web/public/brand/mgo-emby-stacked.png b/web/public/brand/mgo-emby-stacked.png new file mode 100644 index 0000000..1b13a86 Binary files /dev/null and b/web/public/brand/mgo-emby-stacked.png differ diff --git a/web/public/brand/mgo-emby-stacked.svg b/web/public/brand/mgo-emby-stacked.svg new file mode 100644 index 0000000..97d2ebb --- /dev/null +++ b/web/public/brand/mgo-emby-stacked.svg @@ -0,0 +1,16 @@ + + + + + + + Mgo-Emby + 社区 + + + + + + + + diff --git a/web/public/brand/original-size/mgo-emby-horizontal.png b/web/public/brand/original-size/mgo-emby-horizontal.png new file mode 100644 index 0000000..6007880 Binary files /dev/null and b/web/public/brand/original-size/mgo-emby-horizontal.png differ diff --git a/web/public/brand/original-size/mgo-emby-horizontal.svg b/web/public/brand/original-size/mgo-emby-horizontal.svg new file mode 100644 index 0000000..d5e3e22 --- /dev/null +++ b/web/public/brand/original-size/mgo-emby-horizontal.svg @@ -0,0 +1,20 @@ + + + + + + + + + Mgo-Emby + 社区 + + BBS + + + + + + + + diff --git a/web/public/brand/original-size/mgo-emby-icon.png b/web/public/brand/original-size/mgo-emby-icon.png new file mode 100644 index 0000000..5e10e67 Binary files /dev/null and b/web/public/brand/original-size/mgo-emby-icon.png differ diff --git a/web/public/brand/original-size/mgo-emby-icon.svg b/web/public/brand/original-size/mgo-emby-icon.svg new file mode 100644 index 0000000..b4f69ee --- /dev/null +++ b/web/public/brand/original-size/mgo-emby-icon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/web/public/brand/original-size/mgo-emby-monochrome.png b/web/public/brand/original-size/mgo-emby-monochrome.png new file mode 100644 index 0000000..e4bfe80 Binary files /dev/null and b/web/public/brand/original-size/mgo-emby-monochrome.png differ diff --git a/web/public/brand/original-size/mgo-emby-monochrome.svg b/web/public/brand/original-size/mgo-emby-monochrome.svg new file mode 100644 index 0000000..d83367b --- /dev/null +++ b/web/public/brand/original-size/mgo-emby-monochrome.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/web/public/brand/original-size/mgo-emby-stacked.png b/web/public/brand/original-size/mgo-emby-stacked.png new file mode 100644 index 0000000..0b509ca Binary files /dev/null and b/web/public/brand/original-size/mgo-emby-stacked.png differ diff --git a/web/public/brand/original-size/mgo-emby-stacked.svg b/web/public/brand/original-size/mgo-emby-stacked.svg new file mode 100644 index 0000000..0137c59 --- /dev/null +++ b/web/public/brand/original-size/mgo-emby-stacked.svg @@ -0,0 +1,16 @@ + + + + + + + Mgo-Emby + 社区 + + + + + + + + diff --git a/web/public/favicon.svg b/web/public/favicon.svg index d16ea77..1a082dc 100644 --- a/web/public/favicon.svg +++ b/web/public/favicon.svg @@ -1,17 +1,3 @@ - - - - - - - - - - - - - - - + diff --git a/web/src/App.tsx b/web/src/App.tsx index 5311490..fad9dbd 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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() { diff --git a/web/src/api/license.ts b/web/src/api/license.ts index ce0f2a4..4e64e67 100644 --- a/web/src/api/license.ts +++ b/web/src/api/license.ts @@ -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 { diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index 6ebd274..1e4fc33 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -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 */}
-
- -
+ MediaStationGo {(isSidebarOpen || isMobileDrawerOpen) && ( - MediaStation + MediaStationGo )} diff --git a/web/src/pages/LoginPage.tsx b/web/src/pages/LoginPage.tsx index dc4aa26..d151435 100644 --- a/web/src/pages/LoginPage.tsx +++ b/web/src/pages/LoginPage.tsx @@ -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 */}
- - - + src="/brand/mgo-emby-icon.png" + alt="MediaStationGo" + className="mb-4 h-14 w-14 rounded-2xl object-contain shadow-sm" + /> - MediaStation + MediaStationGo
) -} \ No newline at end of file +} diff --git a/web/src/pages/NotifyChannelsPage.tsx b/web/src/pages/NotifyChannelsPage.tsx index 8811b82..5af043d 100644 --- a/web/src/pages/NotifyChannelsPage.tsx +++ b/web/src/pages/NotifyChannelsPage.tsx @@ -129,6 +129,14 @@ const TYPE_LABELS: Record = { 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({ )}
{summary}
+
{eventSummary(channel.events)}