Update MediaStationGo branding and deployment docs

This commit is contained in:
ShukeBta
2026-06-17 16:01:16 +08:00
parent 3368ac2947
commit ab4637beed
62 changed files with 1035 additions and 599 deletions
+190 -438
View File
@@ -1,21 +1,21 @@
# MediaStationGo
<p align="center">
<img src="web/public/favicon.svg" width="96" height="96" alt="MediaStationGo Logo" />
<img src="web/public/brand/mgo-emby-icon.png" width="96" height="96" alt="MediaStationGo Logo" />
</p>
<h3 align="center">轻量、好看、适合 NAS 的私人媒体中心</h3>
<h3 align="center">适合 NAS、家庭共享和多端播放的私人媒体中心</h3>
<p align="center">
<strong>Docker 一键部署 · 多用户管理 · 媒体库 · 刮削 · 下载整理 · Emby 协议兼容 · 网盘播放</strong>
<strong>Docker 一键部署 · PostgreSQL 主库 · Redis 热缓存 · OpenSearch 搜索增强 · Emby 协议兼容 · Bot 通知</strong>
</p>
<p align="center">
<a href="README_EN.md">English</a> ·
<a href="#快速开始">快速开始</a> ·
<a href="#docker-compose-推荐部署">Docker 部署</a> ·
<a href="#常见问题">常见问题</a> ·
<a href="https://mgo.3jzs.com">在线演示</a>
<a href="#三挡部署">三挡部署</a> ·
<a href="#路径映射">路径映射</a> ·
<a href="#旧-sqlite-迁移">旧 SQLite 迁移</a> ·
<a href="#开发构建">开发构建</a>
</p>
<p align="center">
@@ -27,548 +27,300 @@
---
## 一句话介绍
## 项目简介
MediaStationGo 是一个给个人、家庭 NAS、影音爱好者使用的媒体管理系统。
MediaStationGo 是一个自托管媒体管理系统,面向 NAS、小主机、家庭影音和多用户共享场景。它把媒体库、刮削、下载整理、订阅、网盘播放、Emby 协议兼容、用户权限和 Bot 通知放在一个后台里,目标是让用户只维护一套服务,就能给网页端、手机端、电视端和第三方播放器使用。
你可以用它做这些事:
核心能力:
- 把电影、电视剧、动漫、综艺、音乐整理成漂亮的媒体库。
- 创建多个用户账号,给家人、朋友或不同设备分别管理登录和权限。
- 自动识别文件、补全海报、简介、年份、季集信息。
- 在网页里播放,也可以直接用 MediaStationGo 账号登录 Infuse、VidHub、SenPlayer、Emby 客户端等支持 Emby 协议的第三方播放器。
- 连接 qBittorrent,做搜索、订阅、下载、整理入库。
- 接入 OpenList / CloudDrive2 / WebDAV 等外部存储,支持 STRMURL 与 302 反代播放。
- 在 NAS、小主机、VPS、Windows Docker Desktop 上用 Docker Compose 快速运行。
> 项目还在快速迭代。默认 PostgreSQL 部署请同时备份 `data/` 和 `postgres/`。
---
## 核心特点
- **一个服务端,多端播放**:只部署一次 MediaStationGo,不需要再重复部署 Emby 服务端。
- **兼容 Emby 协议客户端**:第三方播放器按 Emby/Jellyfin 方式添加服务器,直接用 MediaStationGo 账号密码登录。
- **多用户管理**:支持管理员、普通用户、账号启停、有效期、设备管理、Bot 注册/兑换码等家庭共享场景。
- **本地媒体 + 网盘媒体统一管理**:本地硬盘、下载目录、OpenList、CloudDrive2、WebDAV 等资源可以放在同一个后台管理。
- **下载到入库一条龙**:连接 qBittorrent 后,可做搜索、订阅、下载完成整理、刮削入库。
- **NAS 友好**:Docker Compose 部署简单,主数据库在 `postgres/`,运行密钥和配置在 `data/`,适合低功耗 NAS 和小主机长期运行。
---
## 适合谁
- **新手用户**:只想复制一份 `docker-compose.yml`,改几个路径就跑起来。
- **NAS 用户**:想用低资源占用的媒体中心管理本地硬盘和网盘资源。
- **PT / 下载用户**:想把下载、整理、刮削、播放放到一个后台。
- **外部播放器用户**:想用一个 MediaStationGo 账号登录支持 Emby 协议的第三方播放器 APP。
- **家庭共享用户**:想给不同用户分配账号,不想为每个人重复搭一套媒体服务。
- **开发者**:想研究 Go + React 的自托管媒体项目。
---
## 在线演示
- 地址:[https://mgo.3jzs.com](https://mgo.3jzs.com)
- 账号:`admin`
- 密码:`admin123`
> 演示站只用于看功能,请不要填写私人 API Key、站点 Cookie 或真实隐私信息。
---
- **媒体库管理**:电影、电视剧、动漫、综艺、音乐和自定义媒体库统一管理。
- **Emby 协议兼容**:Infuse、VidHub、SenPlayer、Fileball 等客户端可按 Emby/Jellyfin 方式添加服务器。
- **本地 + 网盘**:支持本地硬盘、下载目录、OpenList、CloudDrive2、WebDAV、STRMURL 和 302 反代播放。
- **订阅下载入库**:连接 qBittorrent 后支持搜索、订阅、下载完成整理、刮削和入库通知。
- **多用户与权限**:管理员/普通用户、有效期、成人内容开关、设备管理、注册码和 Telegram Bot 绑定。
- **三挡部署**:按规模选择 PostgreSQL、Redis、OpenSearch,低配 NAS 到大库检索都能覆盖。
## 快速开始
最推荐新手使用 Docker Compose。不要一开始就折腾 `.env`、裸机运行、源码编译。
最推荐使用 Docker Compose。默认模板不依赖 `.env`,复制后按自己的 NAS 路径改 `volumes` 和路径环境变量即可。
```bash
mkdir -p MediaStationGo
cd MediaStationGo
curl -fsSL https://raw.githubusercontent.com/ShukeBta/MediaStationGo/main/docker-compose.yml -o docker-compose.yml
```
编辑 `docker-compose.yml`:
```bash
vi docker-compose.yml
```
然后启动:
```bash
docker compose up -d
```
浏览器打开:
启动后访问:
```text
http://服务器IP:18080
```
默认登录:
默认账号:
```text
账号:admin
密码:admin123
admin / admin123
```
---
首次登录后请立刻修改管理员密码。
## Docker Compose 推荐部署
镜像地址:
仓库里的 `docker-compose.yml` 已经是轻量推荐模板:默认不用 `.env`,默认只启动 `MediaStationGo + PostgreSQL`,适合大多数 NAS。
旧版本如果已经有 `./data/mediastation.db`,首次使用新版 compose 启动时会自动导入到 PostgreSQL;`./data` 仍然要保留,用来保存 JWT 密钥、旧库迁移源和运行数据。
### 三种部署模式
| 模式 | 命令 | 适合场景 |
| --- | --- | --- |
| 轻量模式:PG only | `docker compose up -d` | 大多数 NAS,资源占用最低 |
| 标准模式:PG + Redis | `docker compose -f docker-compose.yml -f docker-compose.standard.yml up -d` | 多用户、Emby 客户端频繁刷新 |
| 搜索增强:PG + Redis + OpenSearch | `docker compose -f docker-compose.yml -f docker-compose.standard.yml -f docker-compose.search.yml up -d` | 超大库、后续独立搜索索引 |
建议从轻量模式开始。Redis 和 OpenSearch 都是增强层,不是源数据库;低配 NAS 不要默认开启 OpenSearch。
### 数据库选择与不再使用 SQLite
新版 Docker Compose 默认使用 PostgreSQL,不再把 SQLite 作为主数据库。判断运行时主库只看这两个配置:
```yaml
environment:
MEDIASTATION_DATABASE_TYPE: postgres
MEDIASTATION_DATABASE_DSN: postgres://mediastation:mediastation@postgres:5432/mediastation?sslmode=disable
```text
GHCR:ghcr.io/shukebta/mediastation-go:latest
Docker Hub 备用:shukbet/mediastationgo:latest
```
`MEDIASTATION_DATABASE_DB_PATH` 只用于旧 SQLite 数据库的一次性导入:
## 三挡部署
- 新部署:直接 `docker compose up -d`,会使用 PostgreSQL,不会创建新的 SQLite 主库。
- 旧版本升级:如果存在 `./data/mediastation.db`,首次启动新版 compose 时会自动导入到 PostgreSQL。
- 导入按主键补齐缺失数据,已有行会跳过;如果中途失败,修复后再次启动会继续补剩余表。
- 成功导入后会在 PostgreSQL 的 `settings` 表写入完成标记,之后即使旧 SQLite 文件还在也不会重复导入。
- Redis 是热缓存,OpenSearch 是搜索索引;它们都不是源数据库,丢失后可以重建。
MediaStationGo 推荐按机器资源和用户规模选择部署档位。三挡都使用 PostgreSQL 作为主数据库;Redis 和 OpenSearch 是增强组件,不替代 PostgreSQL。
旧 SQLite 升级到 PostgreSQL 的建议步骤:
| 档位 | 组件 | 适合场景 | 启动命令 |
| --- | --- | --- | --- |
| 第一档 | MediaStationGo + PostgreSQL | 大多数 NAS、个人/家庭使用、低内存机器 | `docker compose up -d` |
| 第二档 | MediaStationGo + PostgreSQL + Redis | 多用户、Emby 客户端频繁刷新、首页/媒体列表访问较多 | `docker compose -f docker-compose.yml -f docker-compose.standard.yml up -d` |
| 第三档 | MediaStationGo + PostgreSQL + Redis + OpenSearch | 超大媒体库、复杂全文搜索、后续需要独立搜索索引 | `docker compose -f docker-compose.yml -f docker-compose.standard.yml -f docker-compose.search.yml up -d` |
### 第一档:PostgreSQL
第一档是默认推荐部署。它只启动主服务和 PostgreSQL,资源占用最低,适合绝大多数 NAS。
```bash
# 拉取最新镜像
docker compose pull
# 启动第一档:MediaStationGo + PostgreSQL
docker compose up -d
docker compose logs -f mediastation-go
```
看到 `sqlite data migrated to postgres`,或确认网页里的用户、媒体库、设置都正常后,再处理旧 SQLite 文件。
如果你确认以后不再使用 SQLite,也不希望应用再把旧 SQLite 当迁移源,可以这样做:
> 只有在网页确认用户、媒体库、设置、媒体条目都已经出现在 PostgreSQL 后,才做下面这一步。
```yaml
environment:
MEDIASTATION_DATABASE_TYPE: postgres
MEDIASTATION_DATABASE_DSN: postgres://mediastation:mediastation@postgres:5432/mediastation?sslmode=disable
MEDIASTATION_DATABASE_DB_PATH: /data/disabled-sqlite-migration.db
```
然后把宿主机上的旧文件改名或移走作为离线备份:
```bash
mv data/mediastation.db data/mediastation.sqlite.bak
```
裸机或自定义 `config.yaml` 部署时同理:
```yaml
database:
type: postgres
dsn: postgres://mediastation:mediastation@127.0.0.1:5432/mediastation?sslmode=disable
db_path: ""
```
注意:不要删除 `./postgres`。迁移完成后真正的主数据库在 `./postgres`,`./data` 仍要保留,因为里面有 JWT 密钥和运行配置。
### 镜像地址怎么选
两种镜像地址都可以用,选择其中一种写到 `image:` 即可:
| 来源 | 镜像地址 | 适合场景 |
| --- | --- | --- |
| GitHub 仓库镜像 GHCR | `ghcr.io/shukebta/mediastation-go:latest` | 默认推荐,跟随仓库发布 |
| Docker Hub | `shukbet/mediastationgo:latest` | 备用镜像,GHCR 拉取慢或不可用时使用 |
如果想固定版本,请先到仓库 Packages 页面确认 GHCR 是否有对应标签。写法如下:
```yaml
image: ghcr.io/shukebta/mediastation-go:<版本标签>
# GHCR 没有对应标签时,可以用 Docker Hub 备用:
# image: shukbet/mediastationgo:MediaStationGo-v0.0.72
```
如果只想简单部署,直接使用 GHCR 的 `latest` 即可。
手动拉取示例:
```bash
# GitHub 仓库镜像
docker pull ghcr.io/shukebta/mediastation-go:latest
# Docker Hub 备用
docker pull shukbet/mediastationgo:latest
```
你只需要重点看 `volumes` 这一段:
```yaml
volumes:
- ./data:/data
- ./cache:/cache
- ./media:/media
- ./downloads:/downloads
```
含义很简单:
| 左边 | 右边 | 说明 |
| --- | --- | --- |
| `./data` | 主程序 `/data` | 程序配置、JWT 密钥、旧 SQLite 迁移源;主数据库在 `./postgres` |
| `./cache` | 主程序 `/cache` | 缓存目录;可清理 |
| `./media` | `/media` | 媒体库目录;自动整理入库需要可写,网页里添加媒体库时填 `/media/...` |
| `./downloads` | `/downloads` | 下载目录;文件管理和自动整理会用 |
| `./postgres` | PostgreSQL `/var/lib/postgresql/data` | 新版默认主数据库;一定要备份 |
| `./redis` | Redis `/data` | 标准模式才会使用;热缓存,丢失可重建 |
| `./opensearch` | OpenSearch `/usr/share/opensearch/data` | 搜索增强模式才会使用;占用内存较高 |
如果你的媒体在 NAS 真实目录,例如:
关键数据目录:
```text
/vol1/1000/Media
/vol1/1000/Downloads
./postgres PostgreSQL 主数据库,必须备份
./data JWT 密钥、运行配置、旧 SQLite 迁移源
./cache 海报、临时文件、转码缓存,可删除重建
```
就把 compose 改成:
### 第二档:PostgreSQL + Redis
```yaml
volumes:
- ./data:/data
- ./cache:/cache
- /vol1/1000/Media:/media
- /vol1/1000/Downloads:/downloads
第二档在第一档基础上叠加 Redis。Redis 用作热缓存,能减轻多用户和 Emby 客户端频繁刷新时的数据库压力。
environment:
MEDIASTATION_MEDIA_DIR: /vol1/1000/Media
MEDIASTATION_DOWNLOAD_DIR: /vol1/1000/Downloads
```bash
# 启动第二档:基础 compose + Redis 叠加文件
docker compose -f docker-compose.yml -f docker-compose.standard.yml up -d
```
注意:
Redis 数据目录是 `./redis`。它主要保存缓存,通常可重建;真正需要备份的仍然是 `./postgres` 和 `./data`。
- `volumes` 左边是宿主机 / NAS 的真实路径。
- `volumes` 右边是容器里的路径,建议固定用 `/media` 和 `/downloads`。
- 在网页里新建媒体库时,填容器路径,例如 `/media/电影`、`/media/电视剧`。
- 不要把 NAS 绝对路径写成 `./vol1/...`,`./` 表示当前部署目录下面的相对路径。
- Windows Docker Desktop 可以写成 `D:/Media:/media`、`D:/Downloads:/downloads`。
- 如果你只想扫描/播放、不使用自动整理入库,可以手动加 `:ro` 变成只读;只要要整理、重命名、入库,媒体库挂载必须保持读写。
### 第三档:PostgreSQL + Redis + OpenSearch
### 最简单 compose 示例
第三档在第二档基础上叠加 OpenSearch,用于大库全文搜索和独立搜索索引。OpenSearch 常驻内存明显更高,低配 NAS 不建议开启。
仓库根目录的 `docker-compose.yml` 就是这个思路。你也可以手动创建:
```bash
# 启动第三档:基础 compose + Redis + OpenSearch
docker compose -f docker-compose.yml -f docker-compose.standard.yml -f docker-compose.search.yml up -d
```
OpenSearch 数据目录是 `./opensearch`。搜索索引可重建,但重建大库索引会花时间;机器资源足够时再开启第三档。
## 配置示例
仓库内提供三份推荐 Compose 文件:
```text
docker-compose.yml 第一档:MediaStationGo + PostgreSQL
docker-compose.standard.yml 第二档叠加:Redis 热缓存
docker-compose.search.yml 第三档叠加:OpenSearch 搜索增强
```
常用配置片段如下,注释保留为中文,方便直接复制到 NAS 上调整:
```yaml
services:
mediastation-go:
# 镜像二选一:
# GitHub 仓库镜像 GHCR:
image: ghcr.io/shukebta/mediastation-go:latest
# Docker Hub 备用:
# image: shukbet/mediastationgo:latest
restart: unless-stopped
init: true
depends_on:
postgres:
condition: service_healthy
# 访问端口:浏览器打开 http://服务器IP:18080
ports:
# 左边是宿主机访问端口,右边是容器内端口。
- "18080:8080"
# 让容器可以访问宿主机上的 qBittorrent:
# qB 地址可填 http://host.docker.internal:8085
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
# 程序数据,升级前备份这个目录。
# 运行数据:JWT 密钥、配置、旧 SQLite 迁移源。
- ./data:/data
# 缓存目录:海报、临时文件、转码缓存,可删除重建。
- ./cache:/cache
# 新手先用当前目录下的 media/downloads。
# NAS 用户把左边改成真实绝对路径。
- ./media:/media
- ./downloads:/downloads
# 媒体库目录:自动整理/重命名/入库需要写权限。
- /vol1/1000/Media:/media
# 下载目录:qBittorrent 保存目录和自动整理源目录。
- /vol1/1000/Downloads:/downloads
environment:
TZ: Asia/Shanghai
PUID: "1000"
PGID: "1000"
MEDIASTATION_APP_HOST: 0.0.0.0
MEDIASTATION_APP_PORT: 8080
MEDIASTATION_APP_WEB_DIR: /app/web/dist
MEDIASTATION_APP_DATA_DIR: /data
# 轻量模式默认 PostgreSQL;旧 SQLite 会从这个路径自动迁移。
# PostgreSQL 主数据库。
MEDIASTATION_DATABASE_TYPE: postgres
MEDIASTATION_DATABASE_DSN: postgres://mediastation:mediastation@postgres:5432/mediastation?sslmode=disable
# 确认迁移完成后,如需彻底禁用 SQLite 迁移源,可改成 /data/disabled-sqlite-migration.db。
# 旧 SQLite 迁移源:只在从旧版 data/mediastation.db 导入时使用。
MEDIASTATION_DATABASE_DB_PATH: /data/mediastation.db
MEDIASTATION_CACHE_CACHE_DIR: /cache
# 如果上面的 ./media / ./downloads 改成 NAS 真实路径,
# 这里也改成同样的宿主机真实路径。
MEDIASTATION_MEDIA_DIR: ./media
# 路径换算:宿主机路径和容器路径必须一一对应。
MEDIASTATION_MEDIA_DIR: /vol1/1000/Media
MEDIASTATION_MEDIA_CONTAINER_DIR: /media
MEDIASTATION_DOWNLOAD_DIR: ./downloads
MEDIASTATION_DOWNLOAD_DIR: /vol1/1000/Downloads
MEDIASTATION_DOWNLOAD_CONTAINER_DIR: /downloads
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: mediastation
POSTGRES_USER: mediastation
POSTGRES_PASSWORD: mediastation
volumes:
- ./postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U mediastation -d mediastation"]
interval: 10s
timeout: 5s
retries: 10
```
> 说明:PostgreSQL 是主数据库;轻量模式也有进程内短缓存。Redis 是跨进程热缓存,OpenSearch 是搜索增强层,都不是源数据库。
## 路径映射
---
路径映射是 Docker 部署里最容易填错的地方。原则是:`volumes` 左边是宿主机真实路径,右边是容器内路径;环境变量里也要保持对应关系。
## 首次进入后怎么配置
NAS 示例:
1. **新建媒体库**
- 进入「媒体库」页面。
- 路径填容器路径,例如 `/media/电影`。
- 点扫描。
```yaml
volumes:
- /vol1/1000/Docker/moviepilot-v2/media:/vol1/1000/Docker/moviepilot-v2/media
- /vol1/1000/qBittorrent/downloads:/vol1/1000/qBittorrent/downloads
environment:
MEDIASTATION_MEDIA_DIR: /vol1/1000/Docker/moviepilot-v2/media
MEDIASTATION_MEDIA_CONTAINER_DIR: /vol1/1000/Docker/moviepilot-v2/media
MEDIASTATION_DOWNLOAD_DIR: /vol1/1000/qBittorrent/downloads
MEDIASTATION_DOWNLOAD_CONTAINER_DIR: /vol1/1000/qBittorrent/downloads
```
2. **配置下载器**
- 进入「下载器管理」。
- 如果 qBittorrent 在宿主机上,地址通常填 `http://host.docker.internal:8085`。
Windows Docker Desktop 示例:
3. **配置刮削源**
- 进入「系统设置 / 外部 API」。
- 按需填写 TMDb、Bangumi、TheTVDB、Fanart、豆瓣等配置。
```yaml
volumes:
- D:/Media:/media
- D:/Downloads:/downloads
environment:
MEDIASTATION_MEDIA_DIR: D:/Media
MEDIASTATION_MEDIA_CONTAINER_DIR: /media
MEDIASTATION_DOWNLOAD_DIR: D:/Downloads
MEDIASTATION_DOWNLOAD_CONTAINER_DIR: /downloads
```
4. **配置外部播放器**
- 第三方客户端按 Emby/Jellyfin 方式添加服务器。
- 地址填 `http://服务器IP:18080`。
- 用户名和密码填 MediaStationGo 后台创建的账号,不需要单独部署 Emby 服务端。
- 管理员可以在后台/Bot 创建普通用户,让不同用户用自己的账号登录第三方播放器。
如果后台添加媒体库时填的是 `/vol1/...`,Compose 里也建议把同一个 `/vol1/...` 挂进容器,避免自动整理和下载入库时路径不可访问。
5. **配置网盘播放**
- 进入「外部存储」配置 OpenList、CloudDrive2、WebDAV 等。
- 后台播放策略可以选择 STRMURL 或 302 反代。
- 开启哪个,就优先走哪个;都关闭时走普通服务端播放链路。
## 旧 SQLite 迁移
---
新版推荐 PostgreSQL 作为主数据库。`MEDIASTATION_DATABASE_DB_PATH` 不是主库路径,而是旧 SQLite 数据的迁移源。
## 更新、备份、日志
迁移步骤:
### 更新
1. 把旧版 `mediastation.db` 放到 `./data/mediastation.db`。
2. 保持 `MEDIASTATION_DATABASE_DB_PATH: /data/mediastation.db`。
3. 启动一次,确认日志显示迁移完成,网页数据正常。
4. 备份 `./postgres` 和 `./data`。
5. 确认不再需要 SQLite 后,把迁移源改成不存在的路径,例如:
```yaml
environment:
# 已完成 SQLite 迁移后,建议改成不存在的路径,避免下次启动重复检查旧库。
MEDIASTATION_DATABASE_DB_PATH: /data/no-sqlite-migration.db
```
不要删除 `./postgres`。PostgreSQL 已经是主数据库,删除它会丢失账号、媒体库、订阅、配置和历史数据。
## 更新与备份
更新镜像:
```bash
docker compose pull
docker compose up -d
```
### 查看日志
第二档和第三档更新时继续带上叠加文件:
```bash
docker compose logs -f mediastation-go
# 第二档
docker compose -f docker-compose.yml -f docker-compose.standard.yml pull
docker compose -f docker-compose.yml -f docker-compose.standard.yml up -d
# 第三档
docker compose -f docker-compose.yml -f docker-compose.standard.yml -f docker-compose.search.yml pull
docker compose -f docker-compose.yml -f docker-compose.standard.yml -f docker-compose.search.yml up -d
```
### 备份
默认 PostgreSQL 部署重点备份:
必须备份:
```text
data/
postgres/
./postgres PostgreSQL 主数据库
./data JWT 密钥、运行配置、旧 SQLite 迁移源
```
`postgres/` 是主数据库,包含用户、媒体库、设置等核心数据;`data/` 保存 JWT 密钥、运行配置和旧 SQLite 迁移源,也要保留。
如果启用了增强模式,还可以按需备份:
可重建:
```text
redis/ # 热缓存,可不备份
opensearch/ # 搜索索引,可重建;超大库可备份以减少重建时间
./cache 图片缓存、临时文件、转码缓存
./redis Redis 热缓存
./opensearch 搜索索引
```
`cache/` 通常不用备份。如果你仍显式使用 `database.type=sqlite` 的旧部署,主库仍在 `data/mediastation.db`。
## Bot 与通知
### 停止
MediaStationGo 支持 Telegram Bot 绑定、用户菜单、群组管理菜单和事件通知。常见通知事件包括:
```bash
docker compose down
```
- 订阅命中新资源
- 下载任务完成
- 入库完成
- 刮削失败告警
- 系统异常通知
---
管理员可以在后台配置 Bot Token、Chat ID、通知频道和事件类型。群组里管理类命令只允许管理员执行,普通用户只能看到和使用用户命令。
## 常见问题
### 1. 页面打不开?
**启动后还是反复迁移 SQLite?**
先看容器是否启动:
```bash
docker ps
docker compose logs --tail=100 mediastation-go
```
确认浏览器访问的是:
```text
http://服务器IP:18080
```
### 2. 媒体库扫描不到文件?
最常见原因是路径写错。
- Docker `volumes` 右边是 `/media`。
- 网页媒体库路径就应该填 `/media/电影`,不要填 NAS 原始路径。
- 如果 qB 下载目录是 `/downloads`,自动整理源目录也优先填 `/downloads`。
### 3. qBittorrent 连不上?
如果 qB 在宿主机上,地址试试:
```text
http://host.docker.internal:8085
```
如果 qB 在另一台机器上,填那台机器的局域网 IP。
### 4. NAS CPU 占用高?
建议先在系统设置里确认:
- `ffprobe.max_concurrent` 设为 `1`。
- 自动整理、扫描后刮削、启动后扫描网盘按需开启。
- 大媒体库不要频繁全量扫描,优先手动扫描或夜间同步。
### 5. 要不要用 `.env`?
新手不建议。直接改 `docker-compose.yml` 最直观。
`.env` 适合进阶用户在多台机器复用同一份 compose。仓库保留 `docker-compose.simple.env.example`,但它不是推荐主线。
---
## 功能概览
| 分类 | 功能 |
| --- | --- |
| 媒体库 | 电影、电视剧、动漫、综艺、音乐、成人内容 |
| 元数据 | NFO、本地图片、TMDb、TheTVDB、Bangumi、豆瓣、Fanart、JavBus/JavDB |
| 播放 | Web 播放、Range 拖动、HLS 转码、直链、STRMURL、302 反代 |
| 外部客户端 | Emby 协议兼容接口,MediaStationGo 账号可直接登录第三方播放器 |
| 用户管理 | 多用户、管理员/普通用户、账号有效期、设备管理、Bot 注册与兑换码 |
| 下载 | qBittorrent、站点搜索、订阅、下载完成后整理 |
| 文件管理 | 浏览、整理、复制、移动、硬链接、软链接 |
| 运维 | 任务队列、回收站、重复文件、通知渠道、运行日志 |
| AI | OpenAI Compatible API、AI 搜索、推荐、助手 |
---
## 截图
<details open>
<summary><strong>界面预览</strong></summary>
| 登录 | 首页 |
| --- | --- |
| <img src="docs/screenshots/00-login.jpg" alt="登录" width="100%"> | <img src="docs/screenshots/01-home.jpg" alt="首页" width="100%"> |
| 媒体库 | 播放器 |
| --- | --- |
| <img src="docs/screenshots/02-libraries.jpg" alt="媒体库" width="100%"> | <img src="docs/screenshots/06-player.jpg" alt="播放器" width="100%"> |
</details>
---
## 开发者运行
普通用户请优先使用 Docker。开发者可以这样运行:
```bash
go run ./cmd/server
```
前端:
```bash
cd web
npm install
npm run dev
```
测试:
确认旧数据已经迁移成功后,把 `MEDIASTATION_DATABASE_DB_PATH` 改成不存在的路径,例如 `/data/no-sqlite-migration.db`,然后重启容器。
**扫库或入库速度很慢?**
先确认数据库档位和路径映射正确。第一档已经足够大多数场景;第二档 Redis 能缓解频繁刷新造成的数据库压力;第三档主要增强搜索,不会替代媒体扫描本身。网盘扫描还会受网盘接口响应、目录数量和网络质量影响。
**qBittorrent 下载完成后无法整理?**
确认 qBittorrent 保存路径已经通过 `volumes` 挂载进 MediaStationGo 容器,并且 `MEDIASTATION_DOWNLOAD_DIR` 与 `MEDIASTATION_DOWNLOAD_CONTAINER_DIR` 对应正确。
**第三方播放器无法连接?**
确认播放器填写的是 `http://服务器IP:18080`,账号密码使用 MediaStationGo 用户账号。反代部署时需要正确设置外部访问地址和 HTTPS 头。
## 开发构建
本地开发需要 Go、Node.js 和 npm。
```bash
# 后端测试
go test ./...
cd web && npm run build
# 前端依赖与构建
npm --prefix web install
npm --prefix web run build
# 本地运行后端
go run ./cmd/server
# 本地运行前端开发服务器
npm --prefix web run dev
```
---
前端开发服务器默认访问:
## 社区与友链
```text
http://127.0.0.1:3000
```
- Telegram MediaStationGo交流群:<https://t.me/MediaStationGo>
- NodeSeek:[https://www.nodeseek.com/](https://www.nodeseek.com/)
- LINUX DO:[https://linux.do/](https://linux.do/)
后端健康检查:
---
```text
http://127.0.0.1:8080/api/health
```
## 赞赏
## 许可证
如果这个项目节省了你的时间,欢迎请作者吃桶泡面。
<img width="200" height="200" alt="微信赞赏码" src="https://github.com/user-attachments/assets/d6077de5-8305-400d-8b82-470ef05d926e" />
---
## Star History
<a href="https://www.star-history.com/?repos=ShukeBta%2FMediaStationGo&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=ShukeBta/MediaStationGo&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=ShukeBta/MediaStationGo&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=ShukeBta/MediaStationGo&type=date&legend=top-left" />
</picture>
</a>
---
## 许可证与非商用声明
本项目基础许可证遵循 `GPL-3.0`,详见 [LICENSE](LICENSE)。
项目维护者同时声明并倡议:
- 本项目主要面向个人学习、家庭 NAS、自建影音、非商业研究与社区共建场景。
- 未经作者明确书面许可,不得将本项目或衍生版本用于商业售卖、商业托管、付费 SaaS、预装售卖设备、闭源二次分发或其他商业化牟利用途。
- 如需商业合作、企业内部部署、定制开发、集成发行或商业授权,请先联系作者确认授权边界。
- 若 README 的非商用声明与 `GPL-3.0` 正式许可文本存在解释差异,代码授权以 [LICENSE](LICENSE) 文件为准,商业使用请额外取得作者许可。
---
<p align="center">Made with ❤️ by ShukeBta</p>
本项目使用 GPL-3.0 License。详见 [LICENSE](LICENSE)。
+1 -1
View File
@@ -1,7 +1,7 @@
# MediaStationGo
<p align="center">
<img src="web/public/favicon.svg" width="96" height="96" alt="MediaStationGo Logo" />
<img src="web/public/brand/mgo-emby-icon.png" width="96" height="96" alt="MediaStationGo Logo" />
</p>
<h3 align="center">A lightweight, polished, NAS-friendly private media center</h3>
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+5 -2
View File
@@ -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
+5 -2
View File
@@ -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
+6 -6
View File
@@ -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 全量源代码
+1 -1
View File
@@ -2,7 +2,7 @@
> **版本**: v1.0 | **日期**: 2026-02-04 | **作者**: Architect (Bob)
>
> 本文档基于原版 MediaStation(~220 API)与当前 Go 版实现(~50+ API)的差距分析,
> 本文档基于旧版 Python 实现(~220 API)与当前 Go 版实现(~50+ API)的差距分析,
> 设计完整的重构架构方案,涵盖数据模型、文件结构、依赖、任务分解和跨模块约定。
---
+1 -1
View File
@@ -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"`
+1 -1
View File
@@ -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,
+2 -6
View File
@@ -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 {
}
}
}
+25 -7
View File
@@ -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": "可在右上角任务面板查看扫描进度",
})
}
}
+1 -1
View File
@@ -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))
+18 -5
View File
@@ -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 {
+10 -6
View File
@@ -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
}
+2 -2
View File
@@ -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 (
+1 -1
View File
@@ -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)
+8
View File
@@ -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 {
+57
View File
@@ -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)
+25
View File
@@ -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 == "" {
+204 -5
View File
@@ -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))
}
+79 -1
View File
@@ -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) {
+2 -1
View File
@@ -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
}
+62 -1
View File
@@ -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 (减少硬盘损耗).
+24
View File
@@ -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)
+4
View File
@@ -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 资源就存放在这些(用户自定义、任意)目录下,否则会被
+17 -17
View File
@@ -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——后者通常
+1 -1
View File
@@ -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 != "" {
+4 -4
View File
@@ -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" 表示成功
+22
View File
@@ -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
+1 -1
View File
@@ -4,7 +4,7 @@
// converts SRT to WebVTT on the fly so the browser <track> element can
// load them directly.
//
// External-subtitle discovery rules (matching MediaStation Python defaults):
// External-subtitle discovery rules (matching the legacy Python defaults):
//
// 1. Same directory, same basename, different extension.
// 2. Same directory, ".sub/" or "subs/" subdirectory.
+6 -15
View File
@@ -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])
}
}
+1 -14
View File
@@ -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) {
+22 -9
View File
@@ -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)
}
}
+6 -3
View File
@@ -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"]) {
+39 -12
View File
@@ -80,7 +80,19 @@ func (s *TelegramBotService) mainMenu(ctx context.Context, channel *model.Notify
)
}
if isAdmin {
header += "\n\n" + telegramGroupPrivateAdminHint()
header += "\n\n<b>管理员入口</b>"
rows = append(rows,
[]telegramInlineButton{{Text: "—— 管理员 ——", Data: "noop"}},
[]telegramInlineButton{
{Text: "📊 容量/状态", Data: "adm_capacity"},
{Text: "👥 用户管理", Data: "adm_users"},
},
[]telegramInlineButton{
{Text: "🔓 开注设置", Data: "adm_openreg"},
{Text: "🎟 生成兑换码", Data: "adm_gencode"},
},
[]telegramInlineButton{{Text: "⚙️ 设备策略", Data: "adm_devicepolicy"}},
)
}
return telegramCommandReply{Text: header, Buttons: rows}
}
@@ -190,10 +202,7 @@ func (s *TelegramBotService) handleMenuCallback(ctx context.Context, channel *mo
}
// ── 管理员专属 ──
if isGroup {
if isAdmin {
return telegramCommandReply{Text: telegramGroupPrivateAdminHint()}, true
}
if isGroup && !isAdmin {
return telegramCommandReply{}, true
}
if !isAdmin {
@@ -557,7 +566,7 @@ func (s *TelegramBotService) createUserFromRegistrationCode(ctx context.Context,
var created model.User
var claimed model.RegistrationCode
err := s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("code = ? AND kind = ? AND used_at IS NULL", code, model.RegistrationCodeRegister).
if err := tx.Where("code = ? AND kind = ? AND used_at IS NULL AND used_count < CASE WHEN max_uses > 0 THEN max_uses ELSE 1 END", code, model.RegistrationCodeRegister).
First(&claimed).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errRegistrationCodeAlreadyUsed
@@ -598,8 +607,12 @@ func (s *TelegramBotService) createUserFromRegistrationCode(ctx context.Context,
}
now := time.Now()
res := tx.Model(&model.RegistrationCode{}).
Where("id = ? AND used_at IS NULL", claimed.ID).
Updates(map[string]any{"used_by_user_id": created.ID, "used_at": &now})
Where("id = ? AND used_at IS NULL AND used_count < CASE WHEN max_uses > 0 THEN max_uses ELSE 1 END", claimed.ID).
Updates(map[string]any{
"used_by_user_id": created.ID,
"used_count": gorm.Expr("used_count + 1"),
"used_at": gorm.Expr("CASE WHEN used_count + 1 >= CASE WHEN max_uses > 0 THEN max_uses ELSE 1 END THEN ? ELSE used_at END", now),
})
if res.Error != nil {
return res.Error
}
@@ -607,7 +620,10 @@ func (s *TelegramBotService) createUserFromRegistrationCode(ctx context.Context,
return errRegistrationCodeAlreadyUsed
}
claimed.UsedByUserID = created.ID
claimed.UsedAt = &now
claimed.UsedCount++
if claimed.UsedCount >= claimed.EffectiveMaxUses() {
claimed.UsedAt = &now
}
return nil
})
if err != nil {
@@ -713,7 +729,7 @@ func (s *TelegramBotService) replyGenCode(ctx context.Context, msg *TelegramMess
func (s *TelegramBotService) cmdGenCode(ctx context.Context, msg *TelegramMessage, args []string) telegramCommandReply {
if len(args) < 2 {
return telegramCommandReply{Text: "用法:<code>/gencode register|renew 天数 [有效天数]</code>\n示例:<code>/gencode register 30</code>、<code>/gencode renew 90 7</code>"}
return telegramCommandReply{Text: "用法:<code>/gencode register|renew 天数 [有效天数] [可用次数]</code>\n示例:<code>/gencode register 30</code>、<code>/gencode renew 90 7 5</code>"}
}
kind := strings.ToLower(strings.TrimSpace(args[0]))
switch kind {
@@ -735,11 +751,18 @@ func (s *TelegramBotService) cmdGenCode(ctx context.Context, msg *TelegramMessag
return telegramCommandReply{Text: "有效天数必须是非负整数。"}
}
}
maxUses := 1
if len(args) > 3 {
maxUses, err = strconv.Atoi(args[3])
if err != nil || maxUses <= 0 {
return telegramCommandReply{Text: "可用次数必须是正整数。"}
}
}
createdBy := ""
if u := s.boundUser(ctx, msg.From.ID); u != nil {
createdBy = u.ID
}
code, err := s.generateCode(ctx, kind, days, validDays, createdBy)
code, err := s.generateCodeWithUses(ctx, kind, days, validDays, maxUses, createdBy)
if err != nil {
return telegramCommandReply{Text: "生成失败:" + err.Error()}
}
@@ -752,7 +775,11 @@ func (s *TelegramBotService) cmdGenCode(ctx context.Context, msg *TelegramMessag
if validDays > 0 && code.ExpiresAt != nil {
valid = "有效至 " + code.ExpiresAt.Format("2006-01-02 15:04")
}
return telegramCommandReply{Text: fmt.Sprintf("已生成%s(%s,%s):\n\n<code>%s</code>", kindLabel, dur, valid, code.Code)}
uses := "单次使用"
if code.EffectiveMaxUses() > 1 {
uses = fmt.Sprintf("最多 %d 次", code.EffectiveMaxUses())
}
return telegramCommandReply{Text: fmt.Sprintf("已生成%s(%s,%s,%s):\n\n<code>%s</code>", kindLabel, dur, valid, uses, code.Code)}
}
func (s *TelegramBotService) replyUserList(ctx context.Context) telegramCommandReply {
+2 -1
View File
@@ -5,7 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#faf8f5" />
<meta name="description" content="MediaStationGo — 个人家庭媒体中心" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" href="/brand/mgo-emby-icon.png" />
<link rel="apple-touch-icon" href="/brand/mgo-emby-icon.png" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+20
View File
@@ -0,0 +1,20 @@
<svg width="920" height="160" viewBox="0 0 460 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Logo Mark -->
<rect x="10" y="15" width="50" height="50" rx="14" fill="url(#horizonPurpleGrad)" />
<path d="M26 30 L43 40 L26 50" stroke="white" stroke-width="4" stroke-linecap="square" stroke-linejoin="miter" fill="none" />
<path d="M26 43 L26 54 L34 48" stroke="white" stroke-width="4" stroke-linecap="square" stroke-linejoin="miter" fill="none" />
<circle cx="46" cy="28" r="3.5" fill="#06B6D4" />
<!-- Typography -->
<text x="75" y="47" font-family="'Plus Jakarta Sans', system-ui, sans-serif" font-weight="800" font-size="28" fill="#111827" letter-spacing="-0.5">Mgo-Emby</text>
<text x="238" y="47" font-family="'Plus Jakarta Sans', system-ui, sans-serif" font-weight="300" font-size="28" fill="#9CA3AF" letter-spacing="-0.5"> 社区</text>
<rect x="315" y="26" width="48" height="22" rx="6" fill="#06B6D4" fill-opacity="0.1" stroke="#06B6D4" stroke-width="1" />
<text x="339" y="41" font-family="'JetBrains Mono', monospace" font-weight="700" font-size="11" fill="#06B6D4" text-anchor="middle">BBS</text>
<defs>
<linearGradient id="horizonPurpleGrad" x1="10" y1="15" x2="60" y2="65" gradientUnits="userSpaceOnUse">
<stop stop-color="#7C3AED" />
<stop offset="1" stop-color="#5B21B6" />
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

+12
View File
@@ -0,0 +1,12 @@
<svg width="64" height="64" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="32" height="32" rx="8" fill="url(#mgoPurpleGrad)" />
<path d="M11 10 L21 16 L11 22" stroke="white" stroke-width="2.5" stroke-linecap="square" stroke-linejoin="miter" fill="none" />
<path d="M11 18 L11 24 L16 21" stroke="white" stroke-width="2.5" stroke-linecap="square" stroke-linejoin="miter" fill="none" />
<circle cx="23" cy="9" r="2" fill="#06B6D4" />
<defs>
<linearGradient id="mgoPurpleGrad" x1="0" y1="0" x2="32" y2="32" gradientUnits="userSpaceOnUse">
<stop stop-color="#7C3AED" />
<stop offset="1" stop-color="#5B21B6" />
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 705 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg width="200" height="200" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="5" y="5" width="90" height="90" rx="24" stroke="currentColor" stroke-width="6" />
<path d="M35 30 L65 47 L35 64" stroke="currentColor" stroke-width="8" stroke-linecap="square" stroke-linejoin="miter" fill="none" />
<path d="M35 51 L35 72 L48 64" stroke="currentColor" stroke-width="8" stroke-linecap="square" stroke-linejoin="miter" fill="none" />
<circle cx="69" cy="29" r="6" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 525 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+16
View File
@@ -0,0 +1,16 @@
<svg width="400" height="480" viewBox="0 0 200 240" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="40" y="20" width="120" height="120" rx="32" fill="url(#primaryPurpleGrad)" />
<path d="M80 55 L120 80 L80 105" stroke="white" stroke-width="9" stroke-linecap="square" stroke-linejoin="miter" fill="none" />
<path d="M80 87 L80 114 L100 101" stroke="white" stroke-width="9" stroke-linecap="square" stroke-linejoin="miter" fill="none" />
<circle cx="128" cy="52" r="8" fill="#06B6D4" />
<text x="100" y="185" font-family="'Plus Jakarta Sans', system-ui, sans-serif" font-weight="800" font-size="24" fill="#111827" text-anchor="middle" letter-spacing="1">Mgo-Emby</text>
<text x="100" y="215" font-family="'Inter', system-ui, sans-serif" font-weight="500" font-size="14" fill="#06B6D4" text-anchor="middle" letter-spacing="4">社区</text>
<defs>
<linearGradient id="primaryPurpleGrad" x1="40" y1="20" x2="160" y2="140" gradientUnits="userSpaceOnUse">
<stop stop-color="#7C3AED" />
<stop offset="1" stop-color="#5B21B6" />
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

@@ -0,0 +1,20 @@
<svg width="460" height="80" viewBox="0 0 460 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Logo Mark -->
<rect x="10" y="15" width="50" height="50" rx="14" fill="url(#horizonPurpleGrad)" />
<path d="M26 30 L43 40 L26 50" stroke="white" stroke-width="4" stroke-linecap="square" stroke-linejoin="miter" fill="none" />
<path d="M26 43 L26 54 L34 48" stroke="white" stroke-width="4" stroke-linecap="square" stroke-linejoin="miter" fill="none" />
<circle cx="46" cy="28" r="3.5" fill="#06B6D4" />
<!-- Typography -->
<text x="75" y="47" font-family="'Plus Jakarta Sans', system-ui, sans-serif" font-weight="800" font-size="28" fill="#111827" letter-spacing="-0.5">Mgo-Emby</text>
<text x="238" y="47" font-family="'Plus Jakarta Sans', system-ui, sans-serif" font-weight="300" font-size="28" fill="#9CA3AF" letter-spacing="-0.5"> 社区</text>
<rect x="315" y="26" width="48" height="22" rx="6" fill="#06B6D4" fill-opacity="0.1" stroke="#06B6D4" stroke-width="1" />
<text x="339" y="41" font-family="'JetBrains Mono', monospace" font-weight="700" font-size="11" fill="#06B6D4" text-anchor="middle">BBS</text>
<defs>
<linearGradient id="horizonPurpleGrad" x1="10" y1="15" x2="60" y2="65" gradientUnits="userSpaceOnUse">
<stop stop-color="#7C3AED" />
<stop offset="1" stop-color="#5B21B6" />
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 981 B

@@ -0,0 +1,12 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="32" height="32" rx="8" fill="url(#mgoPurpleGrad)" />
<path d="M11 10 L21 16 L11 22" stroke="white" stroke-width="2.5" stroke-linecap="square" stroke-linejoin="miter" fill="none" />
<path d="M11 18 L11 24 L16 21" stroke="white" stroke-width="2.5" stroke-linecap="square" stroke-linejoin="miter" fill="none" />
<circle cx="23" cy="9" r="2" fill="#06B6D4" />
<defs>
<linearGradient id="mgoPurpleGrad" x1="0" y1="0" x2="32" y2="32" gradientUnits="userSpaceOnUse">
<stop stop-color="#7C3AED" />
<stop offset="1" stop-color="#5B21B6" />
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 705 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -0,0 +1,6 @@
<svg width="100" height="100" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="5" y="5" width="90" height="90" rx="24" stroke="currentColor" stroke-width="6" />
<path d="M35 30 L65 47 L35 64" stroke="currentColor" stroke-width="8" stroke-linecap="square" stroke-linejoin="miter" fill="none" />
<path d="M35 51 L35 72 L48 64" stroke="currentColor" stroke-width="8" stroke-linecap="square" stroke-linejoin="miter" fill="none" />
<circle cx="69" cy="29" r="6" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 525 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

@@ -0,0 +1,16 @@
<svg width="200" height="240" viewBox="0 0 200 240" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="40" y="20" width="120" height="120" rx="32" fill="url(#primaryPurpleGrad)" />
<path d="M80 55 L120 80 L80 105" stroke="white" stroke-width="9" stroke-linecap="square" stroke-linejoin="miter" fill="none" />
<path d="M80 87 L80 114 L100 101" stroke="white" stroke-width="9" stroke-linecap="square" stroke-linejoin="miter" fill="none" />
<circle cx="128" cy="52" r="8" fill="#06B6D4" />
<text x="100" y="185" font-family="'Plus Jakarta Sans', system-ui, sans-serif" font-weight="800" font-size="24" fill="#111827" text-anchor="middle" letter-spacing="1">Mgo-Emby</text>
<text x="100" y="215" font-family="'Inter', system-ui, sans-serif" font-weight="500" font-size="14" fill="#06B6D4" text-anchor="middle" letter-spacing="4">社区</text>
<defs>
<linearGradient id="primaryPurpleGrad" x1="40" y1="20" x2="160" y2="140" gradientUnits="userSpaceOnUse">
<stop stop-color="#7C3AED" />
<stop offset="1" stop-color="#5B21B6" />
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+1 -15
View File
@@ -1,17 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="MediaStationGo">
<defs>
<linearGradient id="bg" x1="10" y1="6" x2="54" y2="58" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#7c3aed"/>
<stop offset="0.48" stop-color="#2563eb"/>
<stop offset="1" stop-color="#06b6d4"/>
</linearGradient>
<linearGradient id="play" x1="23" y1="18" x2="45" y2="46" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#fff7ed"/>
<stop offset="1" stop-color="#ffffff"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="18" fill="url(#bg)"/>
<path d="M18 21.5A7.5 7.5 0 0 1 25.5 14h13A7.5 7.5 0 0 1 46 21.5v21A7.5 7.5 0 0 1 38.5 50h-13A7.5 7.5 0 0 1 18 42.5v-21Z" fill="#0f172a" opacity=".22"/>
<path d="M22 22.9c0-3.05 3.32-4.95 5.96-3.42l17.08 9.86c2.64 1.52 2.64 5.34 0 6.86L27.96 46.06C25.32 47.58 22 45.68 22 42.63V22.9Z" fill="url(#play)"/>
<path d="M13 18h6M13 28h6M13 38h6M45 18h6M45 28h6M45 38h6" stroke="#ffffff" stroke-width="3" stroke-linecap="round" opacity=".72"/>
<image href="/brand/mgo-emby-icon.png" width="64" height="64" preserveAspectRatio="xMidYMid meet"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 209 B

+1 -1
View File
@@ -113,7 +113,7 @@ class AppErrorBoundary extends Component<{ children: ReactNode }, { hasError: bo
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error('MediaStation UI crashed', error, info)
console.error('MediaStationGo UI crashed', error, info)
}
render() {
+1 -1
View File
@@ -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 {
+7 -5
View File
@@ -3,7 +3,7 @@ import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router-do
import { AnimatePresence, motion } from 'framer-motion'
import toast from 'react-hot-toast'
import {
Activity, Bell, Clock, CloudDownload, Compass, Film,
Activity, Bell, Clock, CloudDownload, Compass,
Cast, Globe, HardDrive, Heart, Home, Image, KeySquare,
ListMusic, LogOut, Rss, Search, Trash2,
Settings, Sliders, Sparkles, UserCog,
@@ -139,16 +139,18 @@ export function Layout() {
{/* Brand Logo & Brand Title */}
<div className="flex h-20 items-center justify-between px-6 border-b border-gray-100">
<Link to="/" className="flex items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-[#111827] to-[#1f2937] shadow-sm">
<Film className="h-5 w-5 text-[#c9954a]" />
</div>
<img
src="/brand/mgo-emby-icon.png"
alt="MediaStationGo"
className="h-10 w-10 shrink-0 rounded-xl object-contain shadow-sm"
/>
{(isSidebarOpen || isMobileDrawerOpen) && (
<motion.span
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
className="font-display text-lg font-extrabold tracking-tight text-[#111827]"
>
MediaStation
MediaStationGo
</motion.span>
)}
</Link>
+8 -8
View File
@@ -2,7 +2,7 @@
import { useNavigate } from 'react-router-dom'
import { motion } from 'framer-motion'
import toast from 'react-hot-toast'
import { Eye, EyeOff, Film, ArrowRight, ShieldCheck, Lock, User } from 'lucide-react'
import { Eye, EyeOff, ArrowRight, ShieldCheck, Lock, User } from 'lucide-react'
import { AppFooter } from '../components/AppFooter'
import { authAPI } from '../api/auth'
import { useAuthStore } from '../stores/auth'
@@ -50,14 +50,14 @@ export function LoginPage() {
{/* Logo & Headline */}
<div className="flex flex-col items-center text-center pb-8">
<motion.div
<motion.img
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.15, type: 'spring', stiffness: 200 }}
className="mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-[#111827] shadow-sm"
>
<Film className="h-7 w-7 text-[#c9954a]" />
</motion.div>
src="/brand/mgo-emby-icon.png"
alt="MediaStationGo"
className="mb-4 h-14 w-14 rounded-2xl object-contain shadow-sm"
/>
<motion.h1
initial={{ opacity: 0, y: 10 }}
@@ -65,7 +65,7 @@ export function LoginPage() {
transition={{ delay: 0.25 }}
className="font-display text-2xl font-extrabold tracking-tight text-gray-900"
>
MediaStation
MediaStationGo
</motion.h1>
<motion.p
@@ -195,4 +195,4 @@ export function LoginPage() {
</motion.div>
</div>
)
}
}
+50
View File
@@ -129,6 +129,14 @@ const TYPE_LABELS: Record<NotifyChannel['type'], string> = {
email: 'Email',
}
const EVENT_OPTIONS = [
{ value: 'subscription_hit', label: '订阅命中新资源' },
{ value: 'download_complete', label: '下载任务完成' },
{ value: 'library_ingest', label: '入库完成' },
{ value: 'scrape_failed', label: '刮削失败告警' },
{ value: 'system_alert', label: '系统异常通知' },
]
function ChannelCard({
channel,
onTest,
@@ -154,6 +162,7 @@ function ChannelCard({
)}
</div>
<div className="mt-1 truncate text-xs text-ink-50">{summary}</div>
<div className="mt-1 truncate text-xs text-sand-500">{eventSummary(channel.events)}</div>
</div>
<div className="flex shrink-0 gap-2">
<button
@@ -179,6 +188,12 @@ function ChannelCard({
)
}
function eventSummary(events: string[] | undefined): string {
if (!events || events.length === 0) return '事件:全部'
const labels = events.map((event) => EVENT_OPTIONS.find((item) => item.value === event)?.label ?? event)
return `事件:${labels.join('、')}`
}
function channelSummary(ch: NotifyChannel): string {
const cfg = ch.config ?? {}
switch (ch.type) {
@@ -245,6 +260,7 @@ function ChannelFormModal({
const [config, setConfig] = useState<Record<string, string>>(
normalizeInitialConfig(editing?.type ?? 'telegram', editing?.config ?? {}),
)
const [events, setEvents] = useState<string[]>(editing?.events ?? [])
const [enabled, setEnabled] = useState(editing?.enabled ?? true)
const [saving, setSaving] = useState(false)
@@ -275,6 +291,7 @@ function ChannelFormModal({
name: name.trim(),
type: type,
config: cleanedConfig,
events,
enabled,
}
if (editing) {
@@ -296,6 +313,12 @@ function ChannelFormModal({
const updateConfig = (k: string, v: string) =>
setConfig((c) => ({ ...c, [k]: v }))
const toggleEvent = (event: string) => {
setEvents((current) =>
current.includes(event) ? current.filter((item) => item !== event) : [...current, event],
)
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
<div className="glass-panel w-full max-w-lg max-h-[90vh] overflow-y-auto">
@@ -532,6 +555,33 @@ function ChannelFormModal({
</>
)}
<Field label="推送事件">
<div className="space-y-2 rounded-lg border border-gray-200 bg-gray-50/60 p-3">
<label className="flex cursor-pointer items-center gap-2 text-sm text-ink-100">
<input
type="checkbox"
className="h-4 w-4 accent-primary-400"
checked={events.length === 0}
onChange={() => setEvents([])}
/>
全部事件
</label>
<div className="grid gap-2 sm:grid-cols-2">
{EVENT_OPTIONS.map((event) => (
<label key={event.value} className="flex cursor-pointer items-center gap-2 text-sm text-ink-100">
<input
type="checkbox"
className="h-4 w-4 accent-primary-400"
checked={events.includes(event.value)}
onChange={() => toggleEvent(event.value)}
/>
{event.label}
</label>
))}
</div>
</div>
</Field>
<label className="flex cursor-pointer items-center gap-2 text-sm text-ink-100">
<input
type="checkbox"
+1 -1
View File
@@ -140,7 +140,7 @@ const GROUPS: SettingGroup[] = [
{
key: 'license',
label: '授权服务',
description: '连接私有 MediaStationLicenseServer;开源版默认最多 20 个用户,激活后按授权策略提升额度。',
description: '连接私有 MediaStationGo 授权服务;开源版默认最多 20 个用户,激活后按授权策略提升额度。',
items: [
{
key: 'license.server_url',
+1 -1
View File
@@ -312,7 +312,7 @@ export interface NotifyChannel {
name: string
type: 'telegram' | 'wechat' | 'bark' | 'webhook' | 'email'
enabled: boolean
events: string
events: string[]
config: Record<string, string>
created_at: string
updated_at: string