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

- 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 @@ - + + Q|CH(}8X6kDlHyw}G&D>I>i^FtSg3DX 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 + + + + + + + + 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 0000000000000000000000000000000000000000..39e5aca9ae5f5f29eba5ba50bf5c1ef39fd7e9d2 GIT binary patch literal 20391 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 + + + + + + + + + + + diff --git a/web/public/brand/mgo-emby-monochrome.png b/web/public/brand/mgo-emby-monochrome.png new file mode 100644 index 0000000000000000000000000000000000000000..507afe794e1e7bbc974e628e62a89cfdc2a54778 GIT binary patch literal 3874 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 + + + + + diff --git a/web/public/brand/mgo-emby-stacked.png b/web/public/brand/mgo-emby-stacked.png new file mode 100644 index 0000000000000000000000000000000000000000..1b13a86853a8f1d6c0046adf0303e15e86055609 GIT binary patch literal 14452 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 kr%U+`5`(oigVWcvPp0M2b2dH?_b literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..6007880e48756f4680309d1dd0abcc00b4374272 GIT binary patch literal 6153 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 X&G{D$$J + + + + + + + + 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 0000000000000000000000000000000000000000..5e10e67f231581d0487e22c1e6451f29ec45ae10 GIT binary patch literal 981 zcmV;`11kK9P)MvKKHS6g@!cW2Ti#KUz8z!8e(tz4Z6Y z`#v+BhY((Ay3Pdxmg2anQX= z69-V5(2r9IR-B5rpd|h)O5*#Vj_X@c$Mte5sku@uF^fo`QXRDH(#q|3werbjt!xrn z*#tHVFOH+D7iy^)YH1Ht63$s9G2J;Y;CA|fPc`f0c5E43%0VURMv=sXlVVeoj|iP? zs&>2J9syk_5O?18&U{PT?gw5d?nI%m!&w;HAtWBdZ8KcdfdWxG@#hzR-w#p<$k|!XH=6;CjXg)6OY)=QGa= zYvcmKktZ;Ku)wcpjKaC(!m`ta*2Y&oV+Si4<1+c(%2P~*j4x>hCgJys4 z8pG8V4dOe`7tIf*{s;&R&xwBL5fE(W)j2lrk5?-Oz3__Eudo;#J|m#tFQ#xocTV9s z(QiRm+P+#qm0|#;G7eAg+Ogzg!bQ~ShEO0fFvAs5tspEjug)3rf}T{{ecJ6>UTJqn zP{Z-zil{drEQX_P_g5~LXWp07dP0I{N40>kxV@=<#uvQ&_cImHaKRQ5JUj9=2+L7> zaBkFZ2P}s94H=FPKkg9_{4r6f81MmyYB)apcw{q(I<9X%*h}V?qMvuZLo*y7o)z9S zM~bDrJ;9z)x3aCCD>l{4biFdcBbtyEq3;L4SaRcxI_~R4Gnq1FOJP0HNAtnof^(N^Hud5`DW0&pSdVH00000NkvXXu0mjf DbT7&I literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e4bfe807d9acc3ffab87c433adfa24a488b6c13e GIT binary patch literal 1824 zcmV+*2jBRKP)a0}`YdW|>gvLurvzlwBmk zBow--LE7!1mrCF4_TrOCr4{tjYmd^5QKUsV76hr2m1Z=gRwhnrdSb&m@HO-8eXjeQ zea_i`SRljs&e?nY-D~f?*4k@>hlfY;=mO>e>w&GnAaD>k4x9mkipUvh^FisuR_T-K zTZ;;ia0{><_}u|z29`zjaT~B$`tA%a1fB$ba6m~xNiqzq0eZ_MLJiqfz^@J{BydE2 z0alh_&Sn9hIG}*QQQ0XvDg+Mze>l+Tz;QVNtguOh8isEqXwWF|G4K*_FEAUJ1Wceh zm>_LdZNE?YG64LOKo76lFk#)mdodgEHSh#*DfPu=z-sAh9G$!?I?M&Fm}Kk$?x14ia$>1gK=yBROzuNGg6jA?PmvdNlP^06gIz7Bq^YDw<#{!^- za?lU#HB7og@=PAsp!vp@P7mu`CHG(#my3Vr0PrBiqBnw!c2e>fDfesMuYz9V@RR1@ ztFeoB#(ZxOm`^d#nd#VOB_ZZ$-mUV!#^IRe;mO#8T1cGh@Xd3;`@j|0g~0J zNQj!}@<41o7fDD@gljs`$d|xP6*gJU60#uS|IVOI&I2C+GjeFhx`HQlX9)=dIGcfg zQxbCucm=p5LmPTyW{eEwIF~w0NQn7{9c&OoRBrJj88XoOBS^_}V^|47LM#Ek%RtN? zIS!EqJz|n}i78d`x%@5fRc2)f2~#&$XFOl>QJSod0#PYKLQIzVLAN)GbMk4kqKtYMV|-`~0GdOUDtFy0jEGPzyqkRg~JT|CWK69S)?v)`pPw ztiX*i5}S2$R$@QXDF?M9B*YBy2&Ww_>J-=uB^=bOEg>Nm%KjlOF+~T)YEMWwzdV}p zMDT#bXKbSpgoKzVW+<)WvWUP~qX-GnFB5mCXk;9*85nCMA$iR&KLZct92j$&kSK*@ z#f`wH83?fvsca-6{oZ7`LC8cusk=c)mtXd}L&!qEF?55F8Qyi`1|fakx#$KVUEclf z&a!uj4^X+W51H*lxNdC>Pp8~r`y>ZTgaj$XrvX_UAwdG!n;}uINW@-IX5B64*|;Eu zyiPUXbh34xgq)QEVy>fh#}NX9>~^i}cU1`KiBSqKNGXruHk>5HbVNAFC`ZK{x(a6r zF?}=073Vs$Ioxk(EtM;47wiG7md(*&$dT1XkW+qKh=)NN!oP^ByMvY=(CIP_N9 zV*KhhGLLjvYS8P=X|Jmoa;#=ZRiJeCZQAQ9qAqlKrkFm|3eOw#sV?Bmbmh@#MKU{F z!F)prkV8rQaw`q2?sq(9?Fyzzl?a_46J1=~il~FA%B%Wo^YrMGlD0vfH%`oy;=v|T z{7C6MGE8Lr30y@=EH}J3G+%yB`Tv|@GE+ewg>GJp_`s-K%-iH8aks3dqH1_;%Hv}# zxq3Q?5LJTju8JibWJODEVAJh0+GSgwy6KKdN?1R4jX2Zlib>K(-OX!bWxEXv(u!fe z#(IgByZL1a%*>IFR4FFjBO&Z-qz@b9CZZ%KyEfUj8q1_SUfc#XRZd7Hp+YcI0#{{s zI&f45vMg=Wq9s>QA9g?ifg^G#=?>hMon3A45;s8($Ze=j&5vrtES9@nM;uU6U|B{a zxU{GWOZ}E)j;P@Mn_^RkB^fX6+4G + + + + + 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 0000000000000000000000000000000000000000..0b509ca3ad69b82b26368e4c4b37331c293ecb0a GIT binary patch literal 6795 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 literal 0 HcmV?d00001 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)}