Compare commits

...

23 Commits

Author SHA1 Message Date
github-actions[bot] 0320d956ed chore: bump version to 0.0.68 [skip ci] 2026-09-02 02:23:41 +00:00
truewhile 95395c479a 优化前端返回导航与移动端操作体验
优化前端返回导航与移动端操作体验
2026-09-02 10:23:30 +08:00
Cursor Agent eba7904c99 Improve frontend navigation and mobile UX
- Add mobile bottom tab bar for media browsing (Home, Libraries, Favourites, Playlists, More)
- Split mobile drawer: media links first, admin links under a Management section
- Show contextual back button in mobile header for deep routes
- Add reusable PageBackButton and PageHeader components
- Add back navigation to playlist detail, library page, and missing media state
- Embed queue panels in TaskQueuePage to avoid duplicate page titles
- Expose theme toggle in user menu on small screens

Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-02 02:19:25 +00:00
github-actions[bot] 1171fe9464 chore: bump version to 0.0.67 [skip ci] 2026-09-02 02:09:43 +00:00
truewhile 06b96102c8 Merge pull request #4 from truewhile/cursor/rewrite-readme-af8f
更新 README 与 Docker 镜像为 truewhile 仓库
2026-09-02 10:09:31 +08:00
Cursor Agent 077458df31 Update Docker images to ghcr.io/truewhile/mmtl
Replace legacy shukebta/shukbet image references across all compose
templates, system update defaults, and security docs.

Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-02 01:59:48 +00:00
github-actions[bot] 9124c9be9c chore: bump version to 0.0.66 [skip ci] 2026-09-02 01:56:22 +00:00
truewhile 3ae3c6540d 重写 README:更新 fork 说明与当前项目描述
重写 README:更新 fork 说明与当前项目描述
2026-09-02 09:56:06 +08:00
Cursor Agent 313ebdd402 Rewrite README for truewhile fork and current feature set
- Clarify fork lineage from MediaStationGo
- Document current capabilities: Emby mounts, STRM queues, task queue, etc.
- Add acknowledgements for MediaStationGo and qmediasync
- Update repo/image URLs to truewhile/MMTL
- Streamline deployment docs while keeping essential guidance

Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-02 01:53:59 +00:00
github-actions[bot] db52792ada chore: bump version to 0.0.65 [skip ci] 2026-09-02 01:49:22 +00:00
truewhile a8065680bb 优化首页/媒体库加载速度与 SQLite 查询性能
优化首页/媒体库加载速度与 SQLite 查询性能
2026-09-02 09:49:07 +08:00
Cursor Agent 3304f09b1d Fix Auto Docker workflow failing on pull requests
The publish workflow bumps VERSION, pushes commits/tags, and publishes
images. It must not run on pull_request events where checkout is a
detached HEAD and git push fails. PR validation is already covered by ci.yml.

Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-02 01:45:32 +00:00
Cursor Agent ba621cfc4a Optimize home/library loading and SQLite query performance
- Replace home preview N+1 queries with a single window-function batch query
- Cache library previews and series card lists (15s TTL, shared with media cache)
- Tune SQLite: larger page cache, mmap, wal_autocheckpoint, ANALYZE on migrate
- Defer history fetch on library page until last_played sort is selected
- Yield to browser between paginated library loads to keep UI responsive

Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-02 01:39:28 +00:00
github-actions[bot] deca7735a7 chore: bump version to 0.0.64 [skip ci] 2026-09-01 15:16:32 +00:00
truewhile b503fdee7a 优化续播 2026-09-01 23:16:11 +08:00
github-actions[bot] 3fe37e050b chore: bump version to 0.0.63 [skip ci] 2026-09-01 14:43:48 +00:00
truewhile d3233a62c0 优化 2026-09-01 22:43:30 +08:00
github-actions[bot] 165eee7b36 chore: bump version to 0.0.62 [skip ci] 2026-09-01 13:25:14 +00:00
truewhile 78526afc9c 优化 2026-09-01 21:24:55 +08:00
github-actions[bot] f534e0607a chore: bump version to 0.0.61 [skip ci] 2026-09-01 10:55:32 +00:00
truewhile db64a6c093 优化 2026-09-01 18:55:05 +08:00
github-actions[bot] 5c9e7fcaa6 chore: bump version to 0.0.60 [skip ci] 2026-09-01 08:26:41 +00:00
truewhile af67f4cd6e 优化 2026-09-01 16:26:25 +08:00
136 changed files with 3874 additions and 1518 deletions
+1 -3
View File
@@ -4,9 +4,7 @@ name: AuTo Docker Image
on:
push:
branches: [main]
pull_request:
branches: [main]
# 保留手动触发作为备选
workflow_dispatch:
inputs:
+134 -311
View File
@@ -4,20 +4,20 @@
<img src="web/public/brand/logo-192.png" width="96" height="96" alt="MMTL Logo" />
</p>
<h3 align="center">适合 NAS、家庭共享和多端播放的私人媒体中心</h3>
<h3 align="center">面向 NAS 与家庭影音场景的私人媒体中心</h3>
<p align="center">
<strong>Docker 一键部署 · PostgreSQL 主库 · Redis 热缓存 · OpenSearch 搜索增强 · Emby 协议兼容 · Bot 通知</strong>
<strong>媒体库 · 刮削整理 · 网盘 STRM · Emby 协议 · 远程 Emby 挂载 · 多用户权限 · Docker 一键部署</strong>
</p>
<p align="center">
<a href="#项目简介">项目简介</a> ·
<a href="#快速开始">快速开始</a> ·
<a href="#三挡部署">三挡部署</a> ·
<a href="#路径映射">路径映射</a> ·
<a href="#旧-sqlite-迁移">旧 SQLite 迁移</a> ·
<a href="#部署档位">部署档位</a> ·
<a href="#鸣谢">鸣谢</a> ·
<a href="#开发构建">开发构建</a> ·
<a href="CONTRIBUTING.md">贡献规范</a> ·
<a href="https://mgo.3jzs.com">在线演示</a>
<a href="README_EN.md">English</a> ·
<a href="CONTRIBUTING.md">贡献规范</a>
</p>
<p align="center">
@@ -31,400 +31,223 @@
## 项目简介
MMTL 是一个自托管媒体管理系统,面向 NAS、小主机、家庭影音和多用户共享场景。它把媒体库、刮削、下载整理、订阅、网盘播放、Emby 协议兼容、用户权限和 Bot 通知放在一个后台里,目标是让用户只维护一套服务,就能给网页端、手机端、电视端和第三方播放器使用。
**MMTL** 是一个自托管私人媒体管理系统,适合 NAS、小主机、家庭共享和多端播放场景。本项目由 [MediaStationGo](https://github.com/ShukeBta/MediaStationGo) fork 并持续二开维护,在保留「一套服务覆盖网页、手机、电视与第三方播放器」思路的同时,围绕网盘播放、任务队列、远程挂载和权限体系做了大量增强。
核心能力:
你可以把 MMTL 理解为:
- 一个带现代 Web UI 的**媒体库后台**
- 一个兼容 Emby/Jellyfin 客户端的**协议网关**
- 一个连接本地硬盘、下载目录与网盘存储的**整理与播放入口**
### 核心能力
| 模块 | 说明 |
| --- | --- |
| **媒体库** | 电影、电视剧、动漫、综艺、音乐与自定义库;多根目录、扫库、海报墙、继续观看 |
| **元数据刮削** | TMDb、Bangumi、Douban、TheTVDB、Fanart 等;支持 NFO、手动匹配、刮削队列 |
| **播放** | 网页播放器、HLS 转码、弹幕、字幕、播放配置档、观看历史与收藏 |
| **Emby 协议** | Infuse、SenPlayer、Fileball 等客户端可直接添加本服务,使用 MMTL 账号登录 |
| **远程 Emby 挂载** | 将远程 Emby 媒体库挂载到本地界面统一浏览(无需单独开 Emby 客户端) |
| **网盘与 STRM** | OpenList、CloudDrive2、115、WebDAV 等;STRM 同步、上传/下载队列、直链/302 播放 |
| **下载与整理** | qBittorrent 接入、站点搜索与订阅、下载后自动整理、文件管理器(复制/移动/硬链/软链) |
| **用户与权限** | 管理员/普通用户、有效期、成人内容开关、播放配置 PIN、细粒度操作权限 |
| **运维能力** | 统一任务队列、回收站、存储统计、DLNA 投屏、系统设置与日志 |
### 技术栈
- **后端**:Go · Gin · GORM · SQLite / PostgreSQL · 可选 Redis · 可选 OpenSearch
- **前端**:React 18 · Vite · TypeScript · Tailwind CSS · Zustand
- **部署**:Docker Compose 多档模板,支持 amd64 / arm64 镜像与单文件可执行发布
---
- **媒体库管理**:电影、电视剧、动漫、综艺、音乐和自定义媒体库统一管理。
- **Emby 协议兼容**:Infuse、VidHub、SenPlayer、Fileball 等客户端可按 Emby/Jellyfin 方式添加服务器。
- **本地 + 网盘**:支持本地硬盘、下载目录、OpenList、CloudDrive2、WebDAV、STRMURL 和 302 反代播放。
- **订阅下载入库**:连接 qBittorrent 后支持搜索、订阅、下载完成整理、刮削和入库通知。
- **多用户与权限**:管理员/普通用户、有效期、成人内容开关、设备管理、注册码和 Telegram Bot 绑定。
- **灵活部署**:单镜像 SQLite 一键起步,或按规模选择 PostgreSQL、Redis、OpenSearch,低配 NAS 到大库检索都能覆盖。
## 快速开始
最推荐使用 Docker Compose。仓库提供四份独立完整模板,全部不依赖 `.env`。想最省心就下载单镜像档(SQLite,只有一个镜像);只需要按需修改访问端口、媒体目录、下载目录和可选硬件设备。需要多用户/高并发再选第一档起的 PostgreSQL 档位。
推荐使用 Docker Compose。仓库提供四份**互相独立**的完整模板,无需 `.env` 即可起步。
```bash
mkdir -p MMTL
cd MMTL
# 最省心:单镜像 + SQLite,只启动一个容器
mkdir -p MMTL && cd MMTL
# 最省心:单镜像 + 内置 SQLite
curl -fsSL https://raw.githubusercontent.com/truewhile/MMTL/main/docker-compose.simple.yml -o docker-compose.yml
# 或第一档:PostgreSQL(多用户/高并发更稳)
# 或多用户场景:PostgreSQL 第一档
# curl -fsSL https://raw.githubusercontent.com/truewhile/MMTL/main/docker-compose.yml -o docker-compose.yml
docker compose up -d
```
启动后访问:
浏览器访问:
```text
http://服务器IP:18080
```
默认账号:
```text
admin / admin123
```
首次登录后请立刻修改管理员密码。
默认账号:`admin` / `admin123`(首次登录后请立即修改密码)
镜像地址:
```text
GHCR:ghcr.io/truewhile/mmtl:latest
ghcr.io/truewhile/mmtl:latest
```
---
## 部署档位
MMTL 推荐按机器资源和用户规模选择部署档位。每份 Compose 文件都是完整文件,不需要再叠加多个 `-f`。想一个镜像跑起来就选单镜像档(SQLite);需要多用户 / 高并发时再用 PostgreSQL 三档。Redis 和 OpenSearch 是增强组件,不替代 PostgreSQL。
按机器资源选择档位。每份 Compose 文件均可单独使用,**不要**叠加多个 `-f`。
| 档位 | 完整配置文件 | 组件 | 适合场景 |
| 档位 | 配置文件 | 组件 | 适合场景 |
| --- | --- | --- | --- |
| 单镜像档 | `docker-compose.simple.yml` | MMTL + 内置 SQLite | 新手、单人使用、只想一个镜像跑起来的低配机器 |
| 第一档 | `docker-compose.yml` | MMTL + PostgreSQL | 大多数 NAS、个人/家庭使用、低内存机器 |
| 第二档 | `docker-compose.standard.yml` | MMTL + PostgreSQL + Redis | 多用户、Emby 客户端频繁刷新、首页/媒体列表访问较多 |
| 第三档 | `docker-compose.search.yml` | MMTL + PostgreSQL + Redis + OpenSearch | 超大媒体库、复杂全文搜索、后续需要独立搜索索引 |
| 单镜像档 | `docker-compose.simple.yml` | MMTL + SQLite | 新手、单人、低配 NAS,只想一个容器跑起来 |
| 第一档 | `docker-compose.yml` | MMTL + PostgreSQL | 大多数家庭 NAS,多用户更稳 |
| 第二档 | `docker-compose.standard.yml` | + Redis | 多用户、Emby 客户端频繁刷新、首页/列表访问多 |
| 第三档 | `docker-compose.search.yml` | + OpenSearch | 超大媒体库、复杂全文搜索(内存占用更高) |
### 单镜像档:SQLite(最省心)
### 单镜像档要点
只启动 MMTL 一个镜像,主数据库用内置 SQLite,不需要 PostgreSQL / Redis / `.env`。变量最少、资源占用最低,适合新手和单人使用。日后需要多用户或更高并发时,保留 `./data` 后切换到第一档的 PostgreSQL 即可。
```bash
mkdir -p MMTL
cd MMTL
curl -fsSL https://raw.githubusercontent.com/truewhile/MMTL/main/docker-compose.simple.yml -o docker-compose.yml
docker compose up -d
```
第一次部署通常只需要改 `docker-compose.yml` 里的这几处:
- 只启动 **一个** MMTL 容器,数据在 `./data/mmtl.db`
- 通常只需改端口与媒体目录挂载
- **不要**设置 `MMTL_DATABASE_DSN`,否则会切到 PostgreSQL
```yaml
ports:
- "18080:8080" # 改左边 18080 即可
- "18080:8080"
volumes:
- ./data:/data # 必须备份
- ./media:/media # 改左边为你的媒体目录,例如 /vol1/1000/Media:/media
# - /dev/dri:/dev/dri # Intel 核显硬解需要时取消注释
- ./cache:/cache # 可重建
- ./media:/media # 改成你的媒体目录
```
网页后台添加媒体库时填写容器内路径:
网页添加媒体库时填写容器内路径,例如 `/media`、`/media/电影`。
```text
/media
/media/电影
/media/电视剧
```
### PostgreSQL 档位要点
关键数据目录:
```text
./data JWT 密钥、运行配置、SQLite 主数据库(mmtl.db)——必须备份
./cache 海报/临时缓存,可重建
./media 媒体库
```
> 单镜像模式请不要配置 `MMTL_DATABASE_DSN`;一旦填了 DSN 就会切回 PostgreSQL。
### 第一档:PostgreSQL
第一档是默认推荐部署。它只启动主服务和 PostgreSQL,资源占用最低,适合绝大多数 NAS。
```bash
mkdir -p MMTL
cd MMTL
curl -fsSL https://raw.githubusercontent.com/truewhile/MMTL/main/docker-compose.yml -o docker-compose.yml
docker compose up -d
```
关键数据目录:
```text
./postgres PostgreSQL 主数据库,必须备份
./data JWT 密钥、运行配置、旧 SQLite 迁移源
./cache 海报、临时文件、转码缓存,可删除重建
```
### 第二档:PostgreSQL + Redis
第二档是独立完整文件,包含第一档全部配置并额外启用 Redis。Redis 用作热缓存,能减轻多用户和 Emby 客户端频繁刷新时的数据库压力。
```bash
mkdir -p MMTL
cd MMTL
curl -fsSL https://raw.githubusercontent.com/truewhile/MMTL/main/docker-compose.standard.yml -o docker-compose.yml
docker compose up -d
```
Redis 数据目录是 `./redis`。它主要保存缓存,通常可重建;真正需要备份的仍然是 `./postgres` 和 `./data`。
### 第三档:PostgreSQL + Redis + OpenSearch
第三档是独立完整文件,包含第二档全部配置并额外启用 OpenSearch,用于大库全文搜索和独立搜索索引。OpenSearch 常驻内存明显更高,低配 NAS 不建议开启。
```bash
mkdir -p MMTL
cd MMTL
curl -fsSL https://raw.githubusercontent.com/truewhile/MMTL/main/docker-compose.search.yml -o docker-compose.yml
docker compose up -d
```
OpenSearch 数据目录是 `./opensearch`。搜索索引可重建,但重建大库索引会花时间;机器资源足够时再开启第三档。
## 配置示例
仓库内提供四份推荐 Compose 文件:
```text
docker-compose.simple.yml 单镜像档:MMTL + 内置 SQLite
docker-compose.yml 第一档:MMTL + PostgreSQL
docker-compose.standard.yml 第二档:MMTL + PostgreSQL + Redis
docker-compose.search.yml 第三档:MMTL + PostgreSQL + Redis + OpenSearch
```
仓库只保留面向用户部署和项目维护的必要文件。旧的本地部署脚本、发包脚本、开发机辅助脚本、`.env` 示例和旧高级 Compose 模板已经移除;Linux / Docker 用户按上面四个 Compose 文件部署即可。开发者本地生成的 `bin/`、`data/`、`cache/`、`logs/`、`.tmp/`、`tools/` 等目录已列入 `.gitignore`,不应提交到仓库。
如果直接下载为 `docker-compose.yml`,启动命令统一是:
```bash
docker compose up -d
```
如果保留原始文件名,也可以这样启动:
```bash
docker compose -f docker-compose.simple.yml up -d
docker compose -f docker-compose.standard.yml up -d
docker compose -f docker-compose.search.yml up -d
```
常用配置片段如下,注释保留为中文,方便直接复制到 NAS 上调整:
- 主库在 `./postgres`,配置与密钥在 `./data`
- 若存在旧版 `./data/mmtl.db`,首次启动会自动迁移到 PostgreSQL
- 迁移完成后可将 `MMTL_DATABASE_DB_PATH` 改为不存在路径,避免重复检查:
```yaml
services:
mmtl:
image: ghcr.io/truewhile/MMTL:latest
ports:
# 左边是宿主机访问端口,右边是容器内端口。
- "18080:8080"
volumes:
# 运行数据:JWT 密钥、配置、旧 SQLite 迁移源。
- ./data:/data
# 缓存目录:海报、临时文件、转码缓存,可删除重建。
- ./cache:/cache
# 媒体库目录:自动整理/重命名/入库需要写权限。
- /vol1/1000/Media:/media
environment:
TZ: Asia/Shanghai
# PostgreSQL 主数据库。
MMTL_DATABASE_TYPE: postgres
MMTL_DATABASE_DSN: postgres://mmtl:mmtl@postgres:5432/mmtl?sslmode=disable
# 旧 SQLite 迁移源:只在从旧版 data/mmtl.db 导入时使用。
MMTL_DATABASE_DB_PATH: /data/mmtl.db
# 路径换算:宿主机路径和容器路径必须一一对应。
MMTL_MEDIA_DIR: /vol1/1000/Media
MMTL_MEDIA_CONTAINER_DIR: /media
MMTL_DOWNLOAD_DIR: /vol1/1000/Downloads
MMTL_DOWNLOAD_CONTAINER_DIR: /downloads
MMTL_DATABASE_DB_PATH: /data/no-sqlite-migration.db
```
## 路径映射
### 必须备份与可重建
路径映射是 Docker 部署里最容易填错的地方。原则是:`volumes` 左边是宿主机真实路径,右边是容器内路径;环境变量里也要保持对应关系。
| 路径 | 说明 |
| --- | --- |
| `./data` | JWT 密钥、运行配置、SQLite 主库或迁移源 |
| `./postgres` | PostgreSQL 主库(PG 档位) |
| `./cache` | 海报/转码缓存,可重建 |
| `./redis` | 热缓存,可重建 |
| `./opensearch` | 搜索索引,可重建 |
NAS 示例:
```yaml
volumes:
- /vol1/1000/Docker/moviepilot-v2/media:/vol1/1000/Docker/moviepilot-v2/media
- /vol1/1000/qBittorrent/downloads:/vol1/1000/qBittorrent/downloads
environment:
MMTL_MEDIA_DIR: /vol1/1000/Docker/moviepilot-v2/media
MMTL_MEDIA_CONTAINER_DIR: /vol1/1000/Docker/moviepilot-v2/media
MMTL_DOWNLOAD_DIR: /vol1/1000/qBittorrent/downloads
MMTL_DOWNLOAD_CONTAINER_DIR: /vol1/1000/qBittorrent/downloads
```
Windows Docker Desktop 示例:
```yaml
volumes:
- D:/Media:/media
environment:
MMTL_MEDIA_DIR: D:/Media
MMTL_MEDIA_CONTAINER_DIR: /media
```
如果后台添加媒体库时填的是 `/vol1/...`,Compose 里也建议把同一个 `/vol1/...` 挂进容器,避免自动整理和下载入库时路径不可访问。
## 旧 SQLite 迁移
新版推荐 PostgreSQL 作为主数据库。`MMTL_DATABASE_DB_PATH` 不是主库路径,而是旧 SQLite 数据的迁移源。
迁移步骤:
1. 把旧版 `mmtl.db` 放到 `./data/mmtl.db`。
2. 保持 `MMTL_DATABASE_DB_PATH: /data/mmtl.db`。
3. 启动一次,确认日志显示迁移完成,网页数据正常。
4. 备份 `./postgres` 和 `./data`。
5. 确认不再需要 SQLite 后,把迁移源改成不存在的路径,例如:
```yaml
environment:
# 已完成 SQLite 迁移后,建议改成不存在的路径,避免下次启动重复检查旧库。
MMTL_DATABASE_DB_PATH: /data/no-sqlite-migration.db
```
不要删除 `./postgres`。PostgreSQL 已经是主数据库,删除它会丢失账号、媒体库、订阅、配置和历史数据。
## 日志与 STRM 路径
Compose 模板默认把完整应用日志写入 `./data/logs/app.log`,同时拆分 `./data/logs/warn.log` 和 `./data/logs/error.log`。Docker 自身日志也会保留 10 个 50MB 文件:
```bash
docker compose logs -f mmtl
tail -f ./data/logs/app.log
tail -f ./data/logs/error.log
```
如果要排查订阅、站点搜索、自动整理或 STRM 生成问题,保持 `MMTL_LOGGING_LEVEL: info`;需要更细日志时临时改成 `debug`,确认后再改回 `info`。
STRM 输出目录请使用容器内可写路径,例如 `/data/strm`,或你已经挂载进容器的媒体目录。旧版本保存过 `/app/data/strm` 的部署会在生成时自动迁移到当前 `MMTL_APP_DATA_DIR`,默认就是 `/data`。
## 更新与备份
更新镜像:
### 更新镜像
```bash
docker compose pull mmtl
docker compose up -d --no-deps mmtl
```
不要执行裸 `docker compose pull` 做日常更新。PostgreSQL / Redis / OpenSearch 是数据与缓存基础组件,compose 已设置为 `pull_policy: missing`,首次部署缺镜像时会拉取,日常更新只建议拉取 `mmtl`。需要升级这些基础组件时,请先备份 `./postgres`,再手动修改镜像版本并单独拉取。
日常更新只拉 `mmtl` 服务即可,不要随意 `docker compose pull` 升级 PostgreSQL/Redis/OpenSearch 基础镜像。
如果第二档或第三档保留了原始文件名,更新时指定对应完整文件:
---
```bash
# 第二档
docker compose -f docker-compose.standard.yml pull mmtl
docker compose -f docker-compose.standard.yml up -d --no-deps mmtl
## 路径映射
# 第三档
docker compose -f docker-compose.search.yml pull mmtl
docker compose -f docker-compose.search.yml up -d --no-deps mmtl
Docker 部署最常见的问题是路径填错。记住:
- `volumes` **左侧**是宿主机真实路径,**右侧**是容器内路径
- 网页后台添加媒体库时,应填写**容器内**路径(如 `/media/电影`)
- 若使用自动整理/下载入库,`MMTL_MEDIA_DIR` 与 `MMTL_DOWNLOAD_DIR` 需与挂载一致
NAS 示例:
```yaml
volumes:
- /vol1/1000/Media:/media
- /vol1/1000/Downloads:/downloads
environment:
MMTL_MEDIA_DIR: /vol1/1000/Media
MMTL_MEDIA_CONTAINER_DIR: /media
MMTL_DOWNLOAD_DIR: /vol1/1000/Downloads
MMTL_DOWNLOAD_CONTAINER_DIR: /downloads
```
必须备份:
---
```text
./postgres PostgreSQL 主数据库
./data JWT 密钥、运行配置、旧 SQLite 迁移源
```
## 首次使用建议
可重建:
1. **创建媒体库** → 填写 `/media/...` → 执行扫库
2. **配置元数据源** → 系统设置中添加 TMDb、Bangumi 等 API
3. **(可选)连接 qBittorrent** → 下载客户端设置,宿主机可用 `http://host.docker.internal:8085`
4. **(可选)配置网盘账号** → STRM 管理中添加 OpenList / 115 / WebDAV 等
5. **第三方播放器** → 以 Emby 服务器添加 `http://服务器IP:18080`,使用 MMTL 账号登录
```text
./cache 图片缓存、临时文件、转码缓存
./redis Redis 热缓存
./opensearch 搜索索引
```
---
## 常见问题
**启动后还是反复迁移 SQLite?**
**扫库或入库很慢?**
先确认路径映射与数据库档位。网盘扫描还受接口限速与目录规模影响;大库可考虑第二档 Redis 或第三档 OpenSearch。
确认旧数据已经迁移成功后,把 `MMTL_DATABASE_DB_PATH` 改成不存在的路径,例如 `/data/no-sqlite-migration.db`,然后重启容器。
**qBittorrent 下载后无法整理?**
确认下载目录已通过 `volumes` 挂进容器,且 `MMTL_DOWNLOAD_*` 环境变量对应正确。
**扫库或入库速度很慢?**
**硬链接失败(cross-device link)?**
硬链接要求源与目标在同一文件系统/子卷;跨盘、跨 btrfs 子卷或网盘挂载时请改用复制或软链接。
先确认数据库档位和路径映射正确。第一档已经足够大多数场景;第二档 Redis 能缓解频繁刷新造成的数据库压力;第三档主要增强搜索,不会替代媒体扫描本身。网盘扫描还会受网盘接口响应、目录数量和网络质量影响。
**第三方播放器连不上?**
确认地址为 `http://IP:18080`,使用 MMTL 用户账号;反代部署需正确配置外部 URL 与 HTTPS 头。
**qBittorrent 下载完成后无法整理?**
确认 qBittorrent 保存路径已经通过 `volumes` 挂载进 MMTL 容器,并且 `MMTL_DOWNLOAD_DIR` 与 `MMTL_DOWNLOAD_CONTAINER_DIR` 对应正确。
**硬链接目录在 Docker / NAS 上看不到内容?**
硬链接不能直接链接“目录”本身,只能链接目录里的文件。文件管理器执行目录硬链接时会递归创建目标目录结构,并为每个文件创建硬链接。硬链接还要求源文件和目标文件在容器内属于同一个文件系统/子卷;如果下载目录和媒体目录是两个独立 bind mount、不同硬盘、不同 btrfs 子卷或网盘挂载,系统会返回 `invalid cross-device link`,此时请选择“复制”或“软链接”。
**第三方播放器无法连接?**
确认播放器填写的是 `http://服务器IP:18080`,账号密码使用 MMTL 用户账号。反代部署时需要正确设置外部访问地址和 HTTPS 头。
---
## 开发构建
本地开发需要 Go、Node.js 和 npm。
后端会将 `web/dist` 通过 `go:embed` 编进二进制,因此**在编译 / 运行后端之前要先构建前端**,否则 `web` 包会因为缺少嵌入资源而编译失败。
后端通过 `go:embed` 嵌入 `web/dist`,**编译前必须先构建前端**。
```bash
# 前端依赖与构建(必须先做,产物被 go:embed 打进二进制)
npm --prefix web ci
npm --prefix web run build
# 后端测试
go test ./...
# 本地运行后端(二进制自带前端界面,无需额外 web 目录)
go run ./cmd/server
# 本地运行前端开发服务器
npm --prefix web run dev
go run ./cmd/server # http://127.0.0.1:8080
npm --prefix web run dev # http://127.0.0.1:3000
```
前端开发服务器默认访问:
CI 会在 Release 中提供 Windows / Linux / macOS 的 amd64、arm64 单文件可执行程序。
```text
http://127.0.0.1:3000
```
---
后端健康检查:
## 鸣谢
```text
http://127.0.0.1:8080/api/health
```
MMTL 在 [MediaStationGo](https://github.com/ShukeBta/MediaStationGo) 的基础上 fork 并持续演进。感谢上游项目在媒体库架构、Emby 协议兼容和自托管体验上的奠基工作。
### 交叉编译单文件发布物
项目中许多网盘同步、STRM 与媒体整理相关的设计与实现,也参考了 [qmediasync](https://github.com/qicfan/qmediasync)。感谢该项目的思路与实践经验。
CI(`.github/workflows/Auto-docker-publish.yml`)每次发布会自动为 Windows / Linux(含 Debian) / macOS 交叉编译 amd64 + arm64 的单文件可执行程序,并上传到对应的 GitHub Release。你可以在 Releases 页面下载 `.zip`(Windows)或 `.tar.gz`(Linux / macOS)附件,解压后直接运行其中的 `mmtl`(Windows 为 `mmtl.exe`),无需额外携带前端目录。
本地手动交叉编译某个平台:
```bash
# 先构建前端
npm --prefix web ci && npm --prefix web run build
# 例如:构建 Linux amd64 单文件
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -trimpath -ldflags="-s -w" -o mmtl-linux-amd64 ./cmd/server
```
---
## 贡献与反馈
提交 Bug、功能建议或 Pull Request 前,请先阅读 [贡献规范](CONTRIBUTING.md)。
提交 Issue 或 Pull Request 前,请阅读 [贡献规范](CONTRIBUTING.md) 与 [安全策略](SECURITY.md)。
- Bug 反馈请使用 Issue 模板,并提供部署方式、复现步骤、日志和关键配置。
- 功能建议请说明使用场景、期望行为和可接受的替代方案。
- 安全漏洞请不要公开发 Issue,按 [安全策略](SECURITY.md) 使用私密渠道报告。
- Pull Request 请从独立分支或 fork 分支发起,不要直接向 `main` 推送。
- 分支名建议使用 `fix/...`、`feat/...`、`docs/...` 或 `test/...`,例如 `docs/contribution-guidelines`。
- 提交前按改动范围运行 `go test ./...`、`npm --prefix web run build` 或定向测试,并在 PR 中说明验证结果。
- Bug 请附部署方式、复现步骤与相关日志
- 功能建议请说明使用场景与期望行为
- PR 请从独立分支发起,提交前运行 `go test ./...` 与 `npm --prefix web run build`
---
## Star History
<a href="https://www.star-history.com/?repos=ShukeBta%2FMMTL&type=date&legend=top-left">
<a href="https://www.star-history.com/?repos=truewhile%2FMMTL&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=ShukeBta/MMTL&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=ShukeBta/MMTL&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=ShukeBta/MMTL&type=date&legend=top-left" />
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=truewhile/MMTL&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=truewhile/MMTL&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=truewhile/MMTL&type=date&legend=top-left" />
</picture>
</a>
---
## 许可证
本项目使用 GPL-3.0 License。详见 [LICENSE](LICENSE)。
本项目采用 [GPL-3.0](LICENSE) 许可证。
+116 -472
View File
@@ -4,18 +4,19 @@
<img src="web/public/brand/logo-192.png" width="96" height="96" alt="MMTL Logo" />
</p>
<h3 align="center">A lightweight, polished, NAS-friendly private media center</h3>
<h3 align="center">A self-hosted media center for NAS and home theater</h3>
<p align="center">
<strong>Docker-first setup · Multi-user management · Media library · Metadata · Downloads · Emby-protocol clients · Cloud playback</strong>
<strong>Libraries · Metadata · Cloud STRM · Emby protocol · Remote Emby mounts · Multi-user · Docker-first</strong>
</p>
<p align="center">
<a href="README.md">中文</a> ·
<a href="#overview">Overview</a> ·
<a href="#quick-start">Quick Start</a> ·
<a href="#docker-compose-recommended">Docker Compose</a> ·
<a href="#faq">FAQ</a> ·
<a href="https://mgo.3jzs.com">Live Demo</a>
<a href="#deployment-tiers">Deployment</a> ·
<a href="#acknowledgements">Acknowledgements</a> ·
<a href="#development">Development</a>
</p>
<p align="center">
@@ -27,78 +28,51 @@
---
## What is it?
## Overview
MMTL is a self-hosted media center for personal libraries, home NAS, and home-theater users.
**MMTL** is a self-hosted private media management system for NAS, mini PCs, family sharing, and multi-device playback. This repository is a maintained fork of [MediaStationGo](https://github.com/ShukeBta/MediaStationGo), extended with stronger cloud playback, task queues, remote mounts, and permission controls.
It helps you:
In practice, MMTL gives you:
- Manage movies, TV shows, anime, variety shows, music, and adult libraries.
- Create multiple user accounts for family members, friends, or different devices.
- Scan files and enrich posters, summaries, years, seasons, and episodes.
- Play in the web UI, or log in with a MMTL account from Emby-protocol apps such as Infuse, VidHub, SenPlayer, and Emby clients.
- Connect qBittorrent for search, subscriptions, downloads, and post-download organization.
- Connect OpenList, CloudDrive2, WebDAV, and other storage backends with STRMURL or 302 redirect playback.
- Run on NAS, mini PCs, VPS, Linux, Windows Docker Desktop, or any Docker-friendly host.
- A modern **web media library**
- An **Emby/Jellyfin-compatible protocol gateway** for third-party players
- A single panel for **local disks, download folders, and cloud storage**
> The project is moving fast. With the default PostgreSQL deployment, back up both `data/` and `postgres/`.
### Key capabilities
---
| Area | Highlights |
| --- | --- |
| **Libraries** | Movies, TV, anime, variety, music, custom libraries; multi-root scanning; poster wall; continue watching |
| **Metadata** | TMDb, Bangumi, Douban, TheTVDB, Fanart, NFO import, manual matching, scrape queue |
| **Playback** | Web player, HLS transcoding, danmaku, subtitles, play profiles, history and favourites |
| **Emby protocol** | Add MMTL in Infuse, SenPlayer, Fileball, etc. and sign in with MMTL accounts |
| **Remote Emby mounts** | Browse remote Emby libraries inside MMTL without a separate Emby client |
| **Cloud & STRM** | OpenList, CloudDrive2, 115, WebDAV; STRM sync; upload/download queues; direct or 302 playback |
| **Downloads & organize** | qBittorrent, site search/subscriptions, post-download organization, file manager |
| **Users & permissions** | Admin/regular users, expiry, NSFW toggle, play-profile PIN, granular permissions |
| **Operations** | Unified task queue, recycle bin, storage stats, DLNA casting, settings and logs |
## Key Highlights
### Tech stack
- **One server, many clients**: deploy MMTL once; you do not need to run a separate Emby server.
- **Emby-protocol compatibility**: add the server in third-party players as an Emby/Jellyfin-compatible server, then log in with your MMTL username and password.
- **Multi-user management**: supports admins, regular users, account enable/disable, expiry dates, device management, Bot registration, and redeem codes.
- **Local + cloud media in one place**: manage local disks, download folders, OpenList, CloudDrive2, WebDAV, and other storage backends from one panel.
- **Download-to-library workflow**: connect qBittorrent for search, subscriptions, download completion organization, and metadata matching.
- **NAS-friendly**: simple Docker Compose deployment. The primary database lives under `postgres/`, while runtime secrets and files live under `data/`.
---
## Who is it for?
- **Beginners** who want to edit one `docker-compose.yml` and start the service.
- **NAS users** who want a low-resource media center for local disks and cloud storage.
- **PT/download users** who want downloads, organization, metadata, and playback in one panel.
- **External-player users** who want to log in to Emby-protocol third-party apps with one MMTL account.
- **Family-sharing users** who want separate user accounts without deploying a separate media server for each person.
- **Developers** who want to study or extend a Go + React self-hosted media app.
---
## Live Demo
- URL: [https://mgo.3jzs.com](https://mgo.3jzs.com)
- Username: `admin`
- Password: `admin123`
> The demo is for feature preview only. Do not save private API keys, tracker cookies, or personal data there.
- **Backend**: Go, Gin, GORM, SQLite or PostgreSQL, optional Redis and OpenSearch
- **Frontend**: React 18, Vite, TypeScript, Tailwind CSS, Zustand
- **Deployment**: Standalone Docker Compose templates, amd64/arm64 images, single-binary releases
---
## Quick Start
Docker Compose is the recommended path. Beginners do not need `.env`, bare-metal binaries, or source builds. Use the single-image SQLite template if you want the smallest possible setup.
Docker Compose is the recommended path. The repo ships four **standalone** templates; no `.env` is required.
```bash
mkdir -p MMTL
cd MMTL
# Simplest option: one MMTL container + SQLite
curl -fsSL https://raw.githubusercontent.com/ShukeBta/MMTL/main/docker-compose.simple.yml -o docker-compose.yml
# Or tier 1: MMTL + PostgreSQL
# curl -fsSL https://raw.githubusercontent.com/ShukeBta/MMTL/main/docker-compose.yml -o docker-compose.yml
```
mkdir -p MMTL && cd MMTL
Edit `docker-compose.yml`:
# Simplest: one container with built-in SQLite
curl -fsSL https://raw.githubusercontent.com/truewhile/MMTL/main/docker-compose.simple.yml -o docker-compose.yml
```bash
vi docker-compose.yml
```
# Or PostgreSQL tier for multi-user setups
# curl -fsSL https://raw.githubusercontent.com/truewhile/MMTL/main/docker-compose.yml -o docker-compose.yml
Start:
```bash
docker compose up -d
```
@@ -108,295 +82,46 @@ Open:
http://SERVER_IP:18080
```
Default login:
Default login: `admin` / `admin123` — change the password immediately.
Image:
```text
Username: admin
Password: admin123
ghcr.io/truewhile/mmtl:latest
```
---
## Docker Compose Recommended
## Deployment tiers
The repository `docker-compose.yml` is the lightweight recommended template: no `.env` required, and by default it only starts `MMTL + PostgreSQL`. This is the best starting point for most NAS users.
Pick one compose file. Do **not** stack multiple `-f` files.
If you already have an older `./data/mmtl.db`, the first start with the new compose file automatically imports it into PostgreSQL. Keep `./data`; it still stores the JWT secret, runtime data, and the old SQLite migration source.
| Tier | File | Stack | Best for |
| --- | --- | --- | --- |
| Single image | `docker-compose.simple.yml` | MMTL + SQLite | Beginners, single-user, low-resource NAS |
| Tier 1 | `docker-compose.yml` | MMTL + PostgreSQL | Most home NAS deployments |
| Tier 2 | `docker-compose.standard.yml` | + Redis | Multi-user, frequent Emby client refreshes |
| Tier 3 | `docker-compose.search.yml` | + OpenSearch | Very large libraries, advanced full-text search |
### Deployment modes
### Single-image notes
| Mode | Command | Best for |
| --- | --- | --- |
| Single image: SQLite | `docker compose -f docker-compose.simple.yml up -d` | Beginners and single-user setups that want one image only, no PostgreSQL/Redis |
| Lightweight: PG only | `docker compose up -d` | Most NAS devices, lowest resource use |
| Standard: PG + Redis | `docker compose -f docker-compose.standard.yml up -d` | Multi-user use and frequent Emby client refreshes |
| Search enhanced: PG + Redis + OpenSearch | `docker compose -f docker-compose.search.yml up -d` | Huge libraries and future standalone search indexing |
- Only one MMTL container; database lives in `./data/mmtl.db`
- Do **not** set `MMTL_DATABASE_DSN` or it switches to PostgreSQL
- Back up `./data`; `./cache` can be rebuilt
Each compose file is standalone. Do not stack multiple `-f` files together.
### PostgreSQL notes
The single-image `docker-compose.simple.yml` runs only MMTL with a built-in SQLite database — the simplest starting point. Do not set `MMTL_DATABASE_DSN` there, or it switches back to PostgreSQL. Move up to the PostgreSQL modes for multi-user or high-concurrency use (keep `./data` when you switch). Redis and OpenSearch are enhancement layers, not source databases. Do not enable OpenSearch by default on low-memory NAS devices.
- Primary DB: `./postgres`; secrets and runtime files: `./data`
- Existing `./data/mmtl.db` migrates automatically on first start
- After migration, point `MMTL_DATABASE_DB_PATH` at a non-existent file to disable re-checks
### Database Choice And Disabling SQLite
### Backup
The current Docker Compose setup uses PostgreSQL by default. SQLite is no longer the primary database in the recommended Docker deployment. The runtime database is controlled by:
```yaml
environment:
MMTL_DATABASE_TYPE: postgres
MMTL_DATABASE_DSN: postgres://mmtl:mmtl@postgres:5432/mmtl?sslmode=disable
```
`MMTL_DATABASE_DB_PATH` is only used as a one-time migration source for old SQLite data:
- Fresh installs: `docker compose up -d` uses PostgreSQL and does not create a new SQLite primary database.
- Upgrades: if `./data/mmtl.db` exists, the first start with the new compose file imports it into PostgreSQL.
- Migration fills missing rows by primary key and skips rows that already exist. If it fails partway through, a later start continues the remaining tables.
- After a successful import, PostgreSQL gets a completion marker in the `settings` table, so the old SQLite file is not imported again.
- Redis is a hot cache and OpenSearch is a search index; neither is a source database.
Recommended SQLite to PostgreSQL upgrade flow:
```bash
docker compose pull mmtl
docker compose up -d --no-deps mmtl
docker compose logs -f mmtl
```
After you see `sqlite data migrated to postgres`, or after the web UI shows your users, libraries, and settings correctly, you can stop using the old SQLite file as a migration source.
To make the deployment PostgreSQL-only after migration, keep PostgreSQL selected and point the old SQLite migration path at a non-existent file:
> Only do this after the web UI confirms that users, libraries, settings, and media rows are already present in PostgreSQL.
```yaml
environment:
MMTL_DATABASE_TYPE: postgres
MMTL_DATABASE_DSN: postgres://mmtl:mmtl@postgres:5432/mmtl?sslmode=disable
MMTL_DATABASE_DB_PATH: /data/disabled-sqlite-migration.db
```
Then rename or move the old host-side SQLite file as an offline backup:
```bash
mv data/mmtl.db data/mmtl.sqlite.bak
```
For bare-metal or custom `config.yaml` deployments, use the same idea:
```yaml
database:
type: postgres
dsn: postgres://mmtl:mmtl@127.0.0.1:5432/mmtl?sslmode=disable
db_path: ""
```
Do not delete `./postgres`. After migration, it is the real primary database. Keep `./data` too, because it stores the JWT secret and runtime files.
### Choose an image source
Both image sources are supported. Pick one and put it in `image:`:
| Source | Image | Best for |
| --- | --- | --- |
| GitHub Container Registry (GHCR) | `ghcr.io/shukebta/mmtl:latest` | Recommended default, follows repository releases |
| Docker Hub | `shukbet/mmtl:latest` | Backup source when GHCR is slow or unavailable |
To pin a version, first confirm the tag exists on the repository Packages page. Use this format:
```yaml
image: ghcr.io/shukebta/mmtl:<version-tag>
# If GHCR does not have that tag, use Docker Hub as the backup:
# image: shukbet/mmtl:MMTL-v0.0.72
```
For the simplest setup, keep GHCR `latest`.
Manual pull examples:
```bash
# GitHub Container Registry
docker pull ghcr.io/shukebta/mmtl:latest
# Docker Hub backup
docker pull shukbet/mmtl:latest
```
Focus on this part:
```yaml
volumes:
- ./data:/data
- ./cache:/cache
- ./media:/media
- ./downloads:/downloads
```
Meaning:
| Host path | Container path | Purpose |
| --- | --- | --- |
| `./data` | app `/data` | Settings, JWT secret, old SQLite migration source; the primary DB is under `./postgres` |
| `./cache` | app `/cache` | Cache; safe to clean when needed |
| `./media` | `/media` | Media libraries; use `/media/...` in the web UI |
| `./downloads` | `/downloads` | Download directory and organization source |
| `./postgres` | PostgreSQL `/var/lib/postgresql/data` | New default primary database; back this up |
| `./redis` | Redis `/data` | Used only in standard mode; hot cache, rebuildable |
| `./opensearch` | OpenSearch `/usr/share/opensearch/data` | Used only in search-enhanced mode; higher memory use |
If your NAS paths are:
```text
/vol1/1000/Media
/vol1/1000/Downloads
```
change the compose file to:
```yaml
volumes:
- ./data:/data
- ./cache:/cache
- /vol1/1000/Media:/media
- /vol1/1000/Downloads:/downloads
environment:
MMTL_MEDIA_DIR: /vol1/1000/Media
MMTL_DOWNLOAD_DIR: /vol1/1000/Downloads
```
Rules:
- The left side of `volumes` is the real path on your host/NAS.
- The right side is the container path. Keep `/media` and `/downloads` unless you know why you are changing them.
- In the web UI, create libraries with container paths such as `/media/Movies` or `/media/TV`.
- Do not write NAS absolute paths as `./vol1/...`; `./` means a folder under the current compose directory.
- On Windows Docker Desktop, paths like `D:/Media:/media` and `D:/Downloads:/downloads` are fine.
- If you only scan/play existing media and never organize into the library, you may add `:ro`; if you use organize/rename/ingest, the media mount must stay writable.
### Minimal compose example
The root `docker-compose.yml` follows this style:
```yaml
services:
mmtl:
# Pick one image source:
# GitHub Container Registry (GHCR):
image: ghcr.io/shukebta/mmtl:latest
# Docker Hub backup:
# image: shukbet/mmtl:latest
restart: unless-stopped
init: true
depends_on:
postgres:
condition: service_healthy
# Browser: http://SERVER_IP:18080
ports:
- "18080:8080"
# Let the container reach qBittorrent running on the host:
# qB URL example: http://host.docker.internal:8085
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
# Application data. Back this up before upgrades.
- ./data:/data
- ./cache:/cache
# Beginners can create ./media and ./downloads.
# NAS users should replace source with real absolute paths.
# create_host_path=false prevents Docker from silently creating an empty
# folder when the host path is wrong.
- type: bind
source: ./media
target: /media
bind:
create_host_path: false
- type: bind
source: ./downloads
target: /downloads
bind:
create_host_path: false
environment:
TZ: Asia/Shanghai
PUID: "1000"
PGID: "1000"
MMTL_APP_HOST: 0.0.0.0
MMTL_APP_PORT: 8080
MMTL_APP_WEB_DIR: /app/web/dist
MMTL_APP_DATA_DIR: /data
# Lightweight mode uses PostgreSQL by default.
# Old SQLite data migrates from this path on first start.
MMTL_DATABASE_TYPE: postgres
MMTL_DATABASE_DSN: postgres://mmtl:mmtl@postgres:5432/mmtl?sslmode=disable
# After migration, change this to /data/disabled-sqlite-migration.db to disable the SQLite migration source.
MMTL_DATABASE_DB_PATH: /data/mmtl.db
MMTL_CACHE_CACHE_DIR: /cache
# Use /media and /downloads in the web UI and downloader by default.
# Only set MMTL_*_DIR to real host paths when migrating old
# libraries/tasks that already stored host paths.
MMTL_MEDIA_DIR: /media
MMTL_MEDIA_CONTAINER_DIR: /media
MMTL_DOWNLOAD_DIR: /downloads
MMTL_DOWNLOAD_CONTAINER_DIR: /downloads
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: mmtl
POSTGRES_USER: mmtl
POSTGRES_PASSWORD: mmtl
volumes:
- ./postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U mmtl -d mmtl"]
interval: 10s
timeout: 5s
retries: 10
```
> Note: PostgreSQL is the primary database. Lightweight mode still has short in-process caching. Redis is a cross-process hot cache, and OpenSearch is a search enhancement layer; neither is a source database.
---
## First-time Setup
1. **Create a library**
- Go to the library page.
- Use a container path such as `/media/Movies`.
- Start a scan.
2. **Connect qBittorrent**
- Go to download client settings.
- If qBittorrent runs on the host, try `http://host.docker.internal:8085`.
3. **Configure metadata providers**
- Go to system settings / external APIs.
- Add TMDb, Bangumi, TheTVDB, Fanart, Douban, or other providers when needed.
4. **Use external players**
- Add the server as an Emby/Jellyfin-compatible server.
- Server URL: `http://SERVER_IP:18080`.
- Use the username and password created in MMTL. No separate Emby server is required.
- Admins can create regular users in the web UI or Bot so each person can log in with their own account.
5. **Use cloud playback**
- Configure OpenList, CloudDrive2, WebDAV, or another provider in storage settings.
- Choose STRMURL or 302 redirect playback in the admin settings.
- The enabled option takes priority. If both are disabled, playback falls back to the normal server playback path.
---
## Update, Backup, Logs
| Path | Notes |
| --- | --- |
| `./data` | JWT secret, config, SQLite DB or migration source |
| `./postgres` | PostgreSQL primary DB |
| `./cache`, `./redis`, `./opensearch` | Rebuildable |
### Update
@@ -405,181 +130,100 @@ docker compose pull mmtl
docker compose up -d --no-deps mmtl
```
### Logs
---
```bash
docker compose logs -f mmtl
tail -f ./data/logs/app.log
tail -f ./data/logs/error.log
## Path mapping
The most common Docker mistake is mixing host paths with container paths.
- Left side of `volumes` = real host/NAS path
- Right side = container path; use `/media/...` in the web UI
- Keep `MMTL_MEDIA_DIR` / `MMTL_DOWNLOAD_DIR` aligned with mounts when organizing or ingesting downloads
Example:
```yaml
volumes:
- /vol1/1000/Media:/media
- /vol1/1000/Downloads:/downloads
environment:
MMTL_MEDIA_DIR: /vol1/1000/Media
MMTL_MEDIA_CONTAINER_DIR: /media
MMTL_DOWNLOAD_DIR: /vol1/1000/Downloads
MMTL_DOWNLOAD_CONTAINER_DIR: /downloads
```
The compose templates keep full application logs in `./data/logs/app.log` and split warnings/errors into `warn.log` and `error.log`. Keep `MMTL_LOGGING_LEVEL=info` while diagnosing subscription, site search, organizer, or STRM generation issues; temporarily switch to `debug` only when deeper tracing is needed.
---
Use a writable container path for STRM output, such as `/data/strm` or a mounted media path. Deployments that previously saved `/app/data/strm` are migrated automatically to the configured `MMTL_APP_DATA_DIR`, which defaults to `/data`.
## First-time setup
### Backup
For the default PostgreSQL deployment, back up:
```text
data/
postgres/
```
`postgres/` is the primary database and contains users, libraries, settings, and media metadata. `data/` stores the JWT secret, runtime files, and optional old SQLite migration source.
If you enabled the extended modes, these are optional:
```text
redis/ # hot cache, safe to rebuild
opensearch/ # search index, rebuildable; backing it up can save reindex time on huge libraries
```
`cache/` is usually not important. If you explicitly still use `database.type=sqlite`, the primary database remains `data/mmtl.db`.
### Stop
```bash
docker compose down
```
1. Create a library with a container path such as `/media/Movies`, then scan
2. Add metadata providers (TMDb, Bangumi, etc.) in system settings
3. Optionally connect qBittorrent (`http://host.docker.internal:8085` when qB runs on the host)
4. Optionally configure cloud accounts under STRM management
5. Add the server in Emby-compatible players at `http://SERVER_IP:18080` using MMTL credentials
---
## FAQ
### 1. The web page does not open
**Library scan is slow**
Check path mapping and DB tier. Cloud scans also depend on API limits and folder size.
Check the container:
**qBittorrent downloads are not organized**
Ensure the download directory is mounted into the container and env vars match.
```bash
docker ps
docker compose logs --tail=100 mmtl
```
**Hardlink fails with cross-device link**
Hardlinks require the same filesystem/subvolume; use copy or symlink across disks or cloud mounts.
Then open:
```text
http://SERVER_IP:18080
```
### 2. The library cannot find files
Most cases are path mistakes.
- Docker maps media to `/media`.
- In the web UI, use `/media/Movies`, not the original NAS path.
- Docker maps downloads to `/downloads`; use `/downloads` as the organization source when possible.
### 3. qBittorrent cannot connect
If qBittorrent is on the host, try:
```text
http://host.docker.internal:8085
```
If qBittorrent is on another machine, use that machine's LAN IP.
### 4. NAS CPU usage is high
Suggested settings:
- Set `ffprobe.max_concurrent` to `1`.
- Enable automatic organization, scrape-after-scan, and boot cloud scan only when you really need them.
- Avoid frequent full-library scans on large libraries. Prefer manual scan or scheduled night sync.
### 5. Should I use `.env`?
Beginners should not. Editing `docker-compose.yml` directly is easier to understand.
`.env` is not required by the provided deployment templates. For the single-image template, edit `docker-compose.simple.yml` directly and only adjust the port, volume paths, and optional hardware device mapping.
**External player cannot connect**
Use `http://IP:18080` and a MMTL user account; reverse proxies need correct external URL and HTTPS headers.
---
## Features
| Area | Features |
| --- | --- |
| Libraries | Movies, TV shows, anime, variety, music, adult content |
| Metadata | NFO, local artwork, TMDb, TheTVDB, Bangumi, Douban, Fanart, JavBus/JavDB |
| Playback | Web playback, HTTP Range, HLS transcoding, direct links, STRMURL, 302 redirect |
| External clients | Emby-protocol compatible APIs; MMTL accounts can log in to third-party players |
| User management | Multi-user accounts, admin/regular users, expiry dates, device management, Bot registration and redeem codes |
| Downloads | qBittorrent, site search, subscriptions, post-download organization |
| File manager | Browse, organize, copy, move, hardlink, symlink |
| Operations | Task queue, recycle bin, duplicate files, notifications, logs |
| AI | OpenAI-compatible API, AI search, recommendations, assistant |
Directory hardlinks are handled by recreating the directory tree and hardlinking
each contained file. Linux cannot hardlink a directory itself. Hardlinks still
require the source and target files to be on the same filesystem/subvolume from
inside the container; if media and downloads are separate bind mounts, disks,
btrfs subvolumes, or cloud mounts, use copy or symlink instead.
## Development
Regular users should use Docker. Developers can run:
The backend embeds `web/dist` via `go:embed`. Build the frontend first.
```bash
go run ./cmd/server
```
npm --prefix web ci
npm --prefix web run build
Frontend:
```bash
cd web
npm install
npm run dev
```
Tests:
```bash
go test ./...
cd web && npm run build
go run ./cmd/server
npm --prefix web run dev
```
---
## Community and Friends
- Telegram group: <https://t.me/MMTL>
- NodeSeek: [https://www.nodeseek.com/](https://www.nodeseek.com/)
- LINUX DO: [https://linux.do/](https://linux.do/)
Release builds ship single-file binaries for Windows, Linux, and macOS on amd64 and arm64.
---
## Donation
## Acknowledgements
If MMTL saves you time, feel free to buy the author a bowl of noodles.
MMTL is forked from and continues to evolve [MediaStationGo](https://github.com/ShukeBta/MediaStationGo). Thank you to the upstream project for the media-library architecture, Emby-protocol compatibility, and self-hosted foundation.
<img width="200" height="200" alt="WeChat Donation QR" src="https://github.com/user-attachments/assets/d6077de5-8305-400d-8b82-470ef05d926e" />
Many cloud sync, STRM, and media-organization ideas in this project were also informed by [qmediasync](https://github.com/qicfan/qmediasync). Thank you for the reference implementation and design patterns.
---
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) and [SECURITY.md](SECURITY.md) before opening issues or pull requests.
---
## Star History
<a href="https://www.star-history.com/?repos=ShukeBta%2FMMTL&type=date&legend=top-left">
<a href="https://www.star-history.com/?repos=truewhile%2FMMTL&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=ShukeBta/MMTL&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=ShukeBta/MMTL&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=ShukeBta/MMTL&type=date&legend=top-left" />
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=truewhile/MMTL&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=truewhile/MMTL&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=truewhile/MMTL&type=date&legend=top-left" />
</picture>
</a>
---
## License and Non-Commercial Statement
## License
This project uses `GPL-3.0` as its base license. See [LICENSE](LICENSE).
The maintainers also state and request:
- The project is intended for personal learning, home NAS, self-hosted media, non-commercial research, and community collaboration.
- Without explicit written permission from the author, do not use this project or derivative versions for commercial resale, paid hosting, paid SaaS, pre-installed commercial devices, closed-source redistribution, or other profit-oriented commercial use.
- For commercial cooperation, enterprise deployment, custom development, integrated redistribution, or commercial authorization, contact the author first.
- If there is any interpretive difference between this README and the formal `GPL-3.0` license text, the code license is governed by [LICENSE](LICENSE); commercial usage should additionally obtain author permission.
---
<p align="center">Made with ❤️ by ShukeBta</p>
This project is licensed under [GPL-3.0](LICENSE).
+1 -1
View File
@@ -7,7 +7,7 @@ MMTL 是自托管媒体系统,常部署在 NAS、家庭网络、Docker、反
我们优先支持以下版本和部署方式的安全修复:
- 当前 `main` 分支。
- 最新发布镜像:`ghcr.io/shukebta/mmtl:latest`。
- 最新发布镜像:`ghcr.io/truewhile/mmtl:latest`。
- README 中推荐的 Docker Compose 第一档、第二档、第三档部署方式。
历史版本、私有魔改镜像、未公开补丁分支和非标准部署仍可报告,但维护者可能要求先在最新 `main` 或最新镜像中复现。
+1 -1
View File
@@ -1 +1 @@
0.0.59
0.0.68
+2 -6
View File
@@ -20,11 +20,7 @@
services:
mmtl:
# 镜像二选一:
# 方式一:GitHub 仓库镜像 GHCR(默认,推荐)
image: ghcr.io/shukebta/mmtl:latest
# 方式二:Docker Hub 备用(GHCR 拉取慢或不可用时使用)
# image: shukbet/mmtl:latest
image: ghcr.io/truewhile/mmtl:latest
restart: unless-stopped
init: true
@@ -101,7 +97,7 @@ services:
MMTL_SEARCH_OPENSEARCH_URL: http://opensearch:9200
MMTL_SEARCH_INDEX: mmtl_media
MMTL_UPDATE_IMAGE: ghcr.io/shukebta/mmtl:latest
MMTL_UPDATE_IMAGE: ghcr.io/truewhile/mmtl:latest
# 默认推荐在网页里使用容器路径 /media。
# 如果旧媒体库已经保存了宿主机路径 /vol1/1000/Media,
+1 -3
View File
@@ -13,9 +13,7 @@
services:
mmtl:
image: ghcr.io/shukebta/mmtl:latest
# Docker Hub 备用:
# image: shukbet/mmtl:latest
image: ghcr.io/truewhile/mmtl:latest
container_name: mmtl
restart: unless-stopped
+2 -6
View File
@@ -17,11 +17,7 @@
services:
mmtl:
# 镜像二选一:
# 方式一:GitHub 仓库镜像 GHCR(默认,推荐)
image: ghcr.io/shukebta/mmtl:latest
# 方式二:Docker Hub 备用(GHCR 拉取慢或不可用时使用)
# image: shukbet/mmtl:latest
image: ghcr.io/truewhile/mmtl:latest
restart: unless-stopped
init: true
@@ -92,7 +88,7 @@ services:
MMTL_CACHE_REDIS_URL: redis://redis:6379/0
MMTL_CACHE_CACHE_DIR: /cache
MMTL_UPDATE_IMAGE: ghcr.io/shukebta/mmtl:latest
MMTL_UPDATE_IMAGE: ghcr.io/truewhile/mmtl:latest
# 路径换算配置。左边宿主机真实路径要和 volumes 左边保持一致。
MMTL_MEDIA_DIR: /media
+2 -6
View File
@@ -16,11 +16,7 @@
services:
mmtl:
# 镜像二选一:
# 方式一:GitHub 仓库镜像 GHCR(默认,推荐)
image: ghcr.io/shukebta/mmtl:latest
# 方式二:Docker Hub 备用(GHCR 拉取慢或不可用时使用)
# image: shukbet/mmtl:latest
image: ghcr.io/truewhile/mmtl:latest
restart: unless-stopped
init: true
@@ -112,7 +108,7 @@ services:
MMTL_CACHE_CACHE_DIR: /cache
# 管理面板热更新默认拉取此镜像,并用 Watchtower 一次性重建当前容器。
MMTL_UPDATE_IMAGE: ghcr.io/shukebta/mmtl:latest
MMTL_UPDATE_IMAGE: ghcr.io/truewhile/mmtl:latest
# 路径换算配置。
# 默认推荐在网页里使用容器路径 /media。
Binary file not shown.

Before

Width:  |  Height:  |  Size: 830 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

+2 -1
View File
@@ -29,7 +29,7 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("database.dsn", "")
v.SetDefault("database.wal_mode", true)
v.SetDefault("database.busy_timeout", 5000)
v.SetDefault("database.cache_size", -20000)
v.SetDefault("database.cache_size", -40000)
v.SetDefault("database.max_open_conns", defaultDatabaseMaxOpenConns)
v.SetDefault("database.max_idle_conns", defaultDatabaseMaxIdleConns)
@@ -43,6 +43,7 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("logging.max_backups", 10)
v.SetDefault("cache.cache_dir", "./cache")
v.SetDefault("cache.images_max_size_mb", 500)
v.SetDefault("cache.cleanup_interval_min", 60)
v.SetDefault("cache.redis_url", "")
v.SetDefault("cache.redis_prefix", "mmtl")
+3
View File
@@ -44,6 +44,9 @@ func (c *Config) normalize() error {
if c.Cache.CacheDir == "" {
c.Cache.CacheDir = filepath.Join(c.App.DataDir, "cache")
}
if c.Cache.ImagesMaxSizeMB < 0 {
c.Cache.ImagesMaxSizeMB = 0
}
if c.Cache.RedisPrefix == "" {
c.Cache.RedisPrefix = "mmtl"
}
+7 -6
View File
@@ -44,11 +44,11 @@ type TranscoderConfig struct {
// AppConfig 保存运行时应用参数。
type AppConfig struct {
Port int `mapstructure:"port"`
Debug bool `mapstructure:"debug"`
Env string `mapstructure:"env"`
DataDir string `mapstructure:"data_dir"`
WebDir string `mapstructure:"web_dir"`
Port int `mapstructure:"port"`
Debug bool `mapstructure:"debug"`
Env string `mapstructure:"env"`
DataDir string `mapstructure:"data_dir"`
WebDir string `mapstructure:"web_dir"`
// HTTPSEnabled 是否仅通过 HTTPS 提供访问。启用时必须同时配置
// SSLCert / SSLKey(或 SSLCertPath / SSLKeyPath),保存后服务会热切换到 HTTPS。
HTTPSEnabled bool `mapstructure:"https_enabled"`
@@ -59,7 +59,7 @@ type AppConfig struct {
// SSLCertPath 是 SSL 证书文件路径;非空时优先于 SSLCert 从文件读取。
SSLCertPath string `mapstructure:"ssl_cert_path"`
// SSLKeyPath 是 SSL 私钥文件路径;非空时优先于 SSLKey 从文件读取。
SSLKeyPath string `mapstructure:"ssl_key_path"`
SSLKeyPath string `mapstructure:"ssl_key_path"`
FFmpegPath string `mapstructure:"ffmpeg_path"`
FFprobePath string `mapstructure:"ffprobe_path"`
// FFprobeMaxConcurrent limits concurrent ffprobe/ffmpeg metadata probes.
@@ -116,6 +116,7 @@ type LoggingConfig struct {
// CacheConfig 控制磁盘转码/刮削缓存。
type CacheConfig struct {
CacheDir string `mapstructure:"cache_dir"`
ImagesMaxSizeMB int `mapstructure:"images_max_size_mb"`
MaxDiskUsageMB int `mapstructure:"max_disk_usage_mb"`
TTLHours int `mapstructure:"ttl_hours"`
AutoCleanup bool `mapstructure:"auto_cleanup"`
+7 -7
View File
@@ -408,13 +408,13 @@ func TestOpenSQLiteMigrationSourceUsesFallbackSourcePath(t *testing.T) {
_ = sqlDB2.Close()
}
}()
_, copied, err := copyModelTables(src2, dst, 2)
if err != nil {
t.Fatal(err)
}
if copied != 2 {
t.Fatalf("copied rows = %d, want 2", copied)
}
_, copied, err := copyModelTables(src2, dst, 2)
if err != nil {
t.Fatal(err)
}
if copied != 2 {
t.Fatalf("copied rows = %d, want 2", copied)
}
var userCount int64
if err := dst.Model(&model.User{}).Where("username = ?", "real-admin").Count(&userCount).Error; err != nil {
t.Fatal(err)
+34 -1
View File
@@ -20,12 +20,23 @@ func AutoMigrate(db *gorm.DB) error {
if err := ensureLibraryRootsCompatibility(db); err != nil {
return err
}
if err := ensureEmbyMountsCompatibility(db); err != nil {
return err
}
if isSQLite(db) {
return ensureMediaSearchIndex(db)
if err := ensureMediaSearchIndex(db); err != nil {
return err
}
return ensureSQLiteQueryOptimizer(db)
}
return nil
}
func ensureSQLiteQueryOptimizer(db *gorm.DB) error {
// Refresh planner statistics so indexes on large media tables are used.
return db.Exec("ANALYZE").Error
}
func ensurePostgresColumnCompatibility(db *gorm.DB) error {
if !isPostgres(db) {
return nil
@@ -77,3 +88,25 @@ func ensurePerformanceIndexes(db *gorm.DB) error {
}
return nil
}
func ensureEmbyMountsCompatibility(db *gorm.DB) error {
if !db.Migrator().HasTable(&model.EmbyMount{}) {
return nil
}
if !db.Migrator().HasColumn(&model.EmbyMount{}, "sort_order") {
if err := db.Migrator().AddColumn(&model.EmbyMount{}, "sort_order"); err != nil {
return err
}
}
// 针对已有数据:如果存在多个 sort_order=0/NULL 的记录,按创建时间顺序赋予稳定递增的序号
var zeroCount int64
if err := db.Model(&model.EmbyMount{}).Where("sort_order = 0 OR sort_order IS NULL").Count(&zeroCount).Error; err == nil && zeroCount > 1 {
var mounts []model.EmbyMount
if err := db.Order("created_at asc, id asc").Find(&mounts).Error; err == nil {
for i, m := range mounts {
_ = db.Exec("UPDATE emby_mounts SET sort_order = ? WHERE id = ?", i, m.ID).Error
}
}
}
return nil
}
@@ -0,0 +1,62 @@
package database
import (
"testing"
"time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"github.com/ShukeBta/MMTL/internal/model"
)
func TestEnsureEmbyMountsCompatibility(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
// Create a table without sort_order simulating an older schema
if err := db.Exec(`CREATE TABLE emby_mounts (
id varchar(36) PRIMARY KEY,
created_at datetime,
updated_at datetime,
deleted_at datetime,
account_id text,
remote_view_id text,
remote_view_name text,
collection_type text,
name text,
proxy_play numeric DEFAULT false,
enabled numeric DEFAULT true
)`).Error; err != nil {
t.Fatal(err)
}
// Insert older rows
now := time.Now()
_ = db.Exec("INSERT INTO emby_mounts (id, name, created_at) VALUES (?, ?, ?)", "m1", "Mount 1", now.Add(-2*time.Hour)).Error
_ = db.Exec("INSERT INTO emby_mounts (id, name, created_at) VALUES (?, ?, ?)", "m2", "Mount 2", now.Add(-1*time.Hour)).Error
// Run compatibility migration
if err := ensureEmbyMountsCompatibility(db); err != nil {
t.Fatalf("ensureEmbyMountsCompatibility failed: %v", err)
}
// Verify column sort_order exists and values are initialized sequentially
if !db.Migrator().HasColumn(&model.EmbyMount{}, "sort_order") {
t.Fatal("expected sort_order column to be added")
}
var m1, m2 model.EmbyMount
if err := db.Where("id = ?", "m1").First(&m1).Error; err != nil {
t.Fatal(err)
}
if err := db.Where("id = ?", "m2").First(&m2).Error; err != nil {
t.Fatal(err)
}
if m1.SortOrder != 0 || m2.SortOrder != 1 {
t.Fatalf("unexpected sort orders: m1=%d, m2=%d", m1.SortOrder, m2.SortOrder)
}
}
+4 -1
View File
@@ -114,7 +114,10 @@ func buildSQLiteDSN(cfg *config.Config) string {
if cfg.Database.CacheSize != 0 {
dsn += fmt.Sprintf("&_pragma=cache_size(%d)", cfg.Database.CacheSize)
}
dsn += "&_pragma=temp_store(MEMORY)&_pragma=mmap_size(268435456)"
dsn += "&_pragma=temp_store(MEMORY)&_pragma=mmap_size(536870912)"
if cfg.Database.WALMode {
dsn += "&_pragma=wal_autocheckpoint(1000)"
}
return dsn
}
+61 -61
View File
@@ -192,77 +192,77 @@ func updateUserStatusHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if req.IsActive {
_ = svc.Repo.UserDevice.SetKickedByUser(c.Request.Context(), userID, false)
} else {
_ = svc.Repo.UserDevice.SetKickedByUser(c.Request.Context(), userID, true)
}
updated, err := svc.Repo.User.FindByID(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
updated.PopulateComputedFields()
c.JSON(http.StatusOK, updated)
if req.IsActive {
_ = svc.Repo.UserDevice.SetKickedByUser(c.Request.Context(), userID, false)
} else {
_ = svc.Repo.UserDevice.SetKickedByUser(c.Request.Context(), userID, true)
}
updated, err := svc.Repo.User.FindByID(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
updated.PopulateComputedFields()
c.JSON(http.StatusOK, updated)
}
}
type adminUpdateUserLibrariesReq struct {
AllowedLibraryIDs *[]string `json:"allowed_library_ids"`
}
type adminUpdateUserLibrariesReq struct {
AllowedLibraryIDs *[]string `json:"allowed_library_ids"`
}
func updateUserLibrariesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req adminUpdateUserLibrariesReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := c.Param("id")
user, err := svc.Repo.User.FindByID(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if user == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
func updateUserLibrariesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req adminUpdateUserLibrariesReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := c.Param("id")
user, err := svc.Repo.User.FindByID(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if user == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
var rawJSON string
if req.AllowedLibraryIDs != nil && len(*req.AllowedLibraryIDs) > 0 {
var cleanIDs []string
for _, id := range *req.AllowedLibraryIDs {
trimmed := strings.TrimSpace(id)
if trimmed != "" {
cleanIDs = append(cleanIDs, trimmed)
}
}
if len(cleanIDs) > 0 {
data, err := json.Marshal(cleanIDs)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
rawJSON = string(data)
var rawJSON string
if req.AllowedLibraryIDs != nil && len(*req.AllowedLibraryIDs) > 0 {
var cleanIDs []string
for _, id := range *req.AllowedLibraryIDs {
trimmed := strings.TrimSpace(id)
if trimmed != "" {
cleanIDs = append(cleanIDs, trimmed)
}
}
updates := map[string]any{"allowed_library_ids": rawJSON}
if err := svc.Repo.User.UpdateFields(c.Request.Context(), userID, updates); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
if len(cleanIDs) > 0 {
data, err := json.Marshal(cleanIDs)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
rawJSON = string(data)
}
updated, err := svc.Repo.User.FindByID(c.Request.Context(), userID)
if err != nil || updated == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reload user"})
return
}
updated.PopulateComputedFields()
c.JSON(http.StatusOK, updated)
}
updates := map[string]any{"allowed_library_ids": rawJSON}
if err := svc.Repo.User.UpdateFields(c.Request.Context(), userID, updates); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
updated, err := svc.Repo.User.FindByID(c.Request.Context(), userID)
if err != nil || updated == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reload user"})
return
}
updated.PopulateComputedFields()
c.JSON(http.StatusOK, updated)
}
}
func annotateProtectedUsers(ctx context.Context, svc *service.Container, users []model.User) error {
firstAdmin, err := svc.Repo.User.FirstAdmin(ctx)
+3
View File
@@ -64,6 +64,9 @@ func updateSettingHandler(svc *service.Container) gin.HandlerFunc {
if req.Key == "transcode.hw_enabled" || req.Key == "transcode.hw_accel" || req.Key == "transcoder.hardware_accel" || req.Key == "transcoder.encoder" {
svc.Transcoder.StopAll()
}
if req.Key == "cache.images_max_size_mb" && svc.Scheduler != nil {
_ = svc.Scheduler.RunNowAsync(c.Request.Context(), "image_cache_cleanup")
}
c.Status(http.StatusNoContent)
}
}
+3 -3
View File
@@ -44,10 +44,10 @@ func TestDeleteUserRefusesRecentRealtimeSession(t *testing.T) {
if w.Code != http.StatusConflict {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
if found, _ := repos.User.FindByID(t.Context(), viewer.ID); found == nil {
t.Fatal("recent realtime user should not be deleted")
}
if found, _ := repos.User.FindByID(t.Context(), viewer.ID); found == nil {
t.Fatal("recent realtime user should not be deleted")
}
}
func TestUpdateUserLibraries(t *testing.T) {
gin.SetMode(gin.TestMode)
@@ -0,0 +1,148 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/golang-jwt/jwt/v5"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/ShukeBta/MMTL/internal/config"
"github.com/ShukeBta/MMTL/internal/middleware"
"github.com/ShukeBta/MMTL/internal/model"
"github.com/ShukeBta/MMTL/internal/repository"
"github.com/ShukeBta/MMTL/internal/service"
)
func TestMountedEmbyPlayingProgressAndResumePipeline(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(model.AllModels()...); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
user := &model.User{
Base: model.Base{ID: "user-1"},
Username: "test_viewer",
PasswordHash: "x",
Role: "user",
Tier: "free",
IsActive: true,
}
if err := repos.User.Create(t.Context(), user); err != nil {
t.Fatalf("create user: %v", err)
}
cfg := &config.Config{}
logger := zap.NewNop()
svc := &service.Container{
Repo: repos,
Emby: service.NewEmbyService(cfg, logger, repos),
Sessions: service.NewSessionTrackerService(logger),
Playback: service.NewPlaybackService(logger, repos),
}
router := gin.New()
// 注册带认证的路由,模拟已登录用户
router.Use(func(c *gin.Context) {
c.Set(middleware.CtxUserID, user.ID)
c.Next()
})
router.POST("/Sessions/Playing/Progress", embyPlayingProgressHandler(svc))
router.GET("/Items", embyItemsHandler(svc))
router.GET("/Users/:userId/Items/Resume", embyResumeItemsHandler(svc))
router.GET("/Sessions", embySessionsHandler(svc))
remoteMediaID := service.EncodeEmbyRemoteID("mount-1", "remote-item-123")
// 1. 测试上报进度:客户端使用小写 query 参数 itemId / positionTicks
progressReq := httptest.NewRequest(
http.MethodPost,
"/Sessions/Playing/Progress?itemId="+remoteMediaID+"&positionTicks=300000000&runTimeTicks=1000000000",
nil,
)
wProgress := httptest.NewRecorder()
router.ServeHTTP(wProgress, progressReq)
if wProgress.Code != http.StatusNoContent {
t.Fatalf("progress status = %d, body = %s", wProgress.Code, wProgress.Body.String())
}
// 验证已持久化到 PlaybackHistory
var hist model.PlaybackHistory
if err := db.Where("user_id = ? AND media_id = ?", user.ID, remoteMediaID).First(&hist).Error; err != nil {
t.Fatalf("playback history not saved: %v", err)
}
if hist.PositionMs != 30000 {
t.Fatalf("expected position_ms = 30000, got %d", hist.PositionMs)
}
// 2. 测试 Filters=IsResumable 能够包含该远程条目
resumableReq := httptest.NewRequest(
http.MethodGet,
"/Items?Filters=IsResumable",
nil,
)
wResumable := httptest.NewRecorder()
router.ServeHTTP(wResumable, resumableReq)
if wResumable.Code != http.StatusOK {
t.Fatalf("items resumable status = %d, body = %s", wResumable.Code, wResumable.Body.String())
}
var resumableEnvelope map[string]any
if err := json.Unmarshal(wResumable.Body.Bytes(), &resumableEnvelope); err != nil {
t.Fatalf("decode resumable: %v", err)
}
// 因为没有配置真实的远程客户端连接,该远程条目在当前离线测试中不会 panic 崩溃,并且正常响应 Envelope
if resumableEnvelope["TotalRecordCount"] == nil {
t.Fatalf("missing TotalRecordCount in resumable envelope")
}
// 3. 测试 /Users/:userId/Items/Resume 别名路由
resumeAliasReq := httptest.NewRequest(
http.MethodGet,
"/Users/"+user.ID+"/Items/Resume",
nil,
)
wResumeAlias := httptest.NewRecorder()
router.ServeHTTP(wResumeAlias, resumeAliasReq)
if wResumeAlias.Code != http.StatusOK {
t.Fatalf("resume alias status = %d, body = %s", wResumeAlias.Code, wResumeAlias.Body.String())
}
// 4. 测试 /Sessions 返回 NowPlayingItem
sessionsReq := httptest.NewRequest(http.MethodGet, "/Sessions", nil)
wSessions := httptest.NewRecorder()
router.ServeHTTP(wSessions, sessionsReq)
if wSessions.Code != http.StatusOK {
t.Fatalf("sessions status = %d, body = %s", wSessions.Code, wSessions.Body.String())
}
var sessionsList []map[string]any
if err := json.Unmarshal(wSessions.Body.Bytes(), &sessionsList); err != nil {
t.Fatalf("decode sessions: %v", err)
}
if len(sessionsList) == 0 {
t.Fatalf("expected at least 1 session")
}
nowPlaying, ok := sessionsList[0]["NowPlayingItem"].(map[string]any)
if !ok || nowPlaying["Id"] != remoteMediaID {
t.Fatalf("expected NowPlayingItem with id %q, got %#v", remoteMediaID, sessionsList[0]["NowPlayingItem"])
}
}
func signMockToken(secret, userID string) string {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub": userID,
"exp": time.Now().Add(time.Hour).Unix(),
})
s, _ := token.SignedString([]byte(secret))
return s
}
+25 -1
View File
@@ -195,4 +195,28 @@ func deleteEmbyMountHandler(svc *service.Container) gin.HandlerFunc {
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
}
type reorderEmbyMountsReq struct {
IDs []string `json:"ids" binding:"required"`
}
// reorderEmbyMountsHandler 批量重排挂载媒体库顺序。
func reorderEmbyMountsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req reorderEmbyMountsReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if svc.EmbyRemote == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "emby remote service not available"})
return
}
if err := svc.EmbyRemote.ReorderMounts(c.Request.Context(), req.IDs); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
@@ -0,0 +1,65 @@
package handler
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/ShukeBta/MMTL/internal/database"
"github.com/ShukeBta/MMTL/internal/model"
"github.com/ShukeBta/MMTL/internal/repository"
"github.com/ShukeBta/MMTL/internal/service"
)
func TestReorderEmbyMountsHandler(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := database.AutoMigrate(db); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
ctx := t.Context()
m1 := &model.EmbyMount{AccountID: "acct-1", RemoteViewID: "view-1", Name: "Mount 1"}
m2 := &model.EmbyMount{AccountID: "acct-1", RemoteViewID: "view-2", Name: "Mount 2"}
_ = repos.EmbyMount.Create(ctx, m1)
_ = repos.EmbyMount.Create(ctx, m2)
svc := &service.Container{
Repo: repos,
EmbyRemote: service.NewEmbyRemoteService(nil, zap.NewNop(), repos, nil),
}
router := gin.New()
router.PUT("/admin/emby/mounts/reorder", reorderEmbyMountsHandler(svc))
body, _ := json.Marshal(map[string]any{
"ids": []string{m2.ID, m1.ID},
})
req := httptest.NewRequest(http.MethodPut, "/admin/emby/mounts/reorder", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
}
list, err := repos.EmbyMount.List(ctx)
if err != nil {
t.Fatal(err)
}
if len(list) != 2 || list[0].ID != m2.ID || list[1].ID != m1.ID {
t.Fatalf("expected order [m2, m1], got [m%s, m%s]", list[0].ID, list[1].ID)
}
}
+28 -11
View File
@@ -12,8 +12,13 @@ import (
type embyPlayingReq struct {
ItemId string `json:"ItemId"`
ItemIDLower string `json:"itemId"`
ID string `json:"Id"`
IDLower string `json:"id"`
PositionTicks int64 `json:"PositionTicks"`
PositionLower int64 `json:"positionTicks"`
RunTimeTicks int64 `json:"RunTimeTicks"`
RunTimeLower int64 `json:"runTimeTicks"`
}
func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
@@ -25,16 +30,25 @@ func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
}
var req embyPlayingReq
_ = c.ShouldBindJSON(&req)
if req.ItemId == "" {
req.ItemId = c.Query("ItemId")
itemID := embyFirstNonEmptyString(req.ItemId, req.ItemIDLower, req.ID, req.IDLower)
if itemID == "" {
itemID = embyFirstNonEmptyString(firstQueryValue(c, "ItemId", "itemId", "Id", "id"))
}
if req.PositionTicks == 0 {
req.PositionTicks, _ = strconv.ParseInt(c.Query("PositionTicks"), 10, 64)
pos := req.PositionTicks
if pos == 0 {
pos = req.PositionLower
}
if req.RunTimeTicks == 0 {
req.RunTimeTicks, _ = strconv.ParseInt(c.Query("RunTimeTicks"), 10, 64)
if pos == 0 {
pos, _ = strconv.ParseInt(firstQueryValue(c, "PositionTicks", "positionTicks"), 10, 64)
}
if req.ItemId == "" {
runTime := req.RunTimeTicks
if runTime == 0 {
runTime = req.RunTimeLower
}
if runTime == 0 {
runTime, _ = strconv.ParseInt(firstQueryValue(c, "RunTimeTicks", "runTimeTicks"), 10, 64)
}
if itemID == "" {
c.Status(http.StatusOK)
return
}
@@ -43,7 +57,10 @@ func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
c.Status(http.StatusUnauthorized)
return
}
_ = svc.Emby.RecordProgress(c.Request.Context(), uid, req.ItemId, req.PositionTicks, req.RunTimeTicks)
if err := svc.Emby.RecordProgress(c.Request.Context(), uid, itemID, pos, runTime); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
stopped := strings.Contains(strings.ToLower(c.FullPath()+" "+c.Request.URL.Path), "stopped")
if svc.Sessions != nil {
svc.Sessions.RecordPlayback(c.Request.Context(), uid, "",
@@ -51,9 +68,9 @@ func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
clientInfo.DeviceName,
clientInfo.Client,
c.ClientIP(),
req.ItemId,
req.PositionTicks,
req.RunTimeTicks,
itemID,
pos,
runTime,
stopped)
}
if svc.Device != nil && !stopped {
+2
View File
@@ -161,6 +161,8 @@ func registerEmbyAuthenticatedItemRoutes(auth *gin.RouterGroup, svc *service.Con
auth.GET("/Users/:userId/Items/Counts", embyItemsCountsHandler(svc))
auth.GET("/Items/Latest", embyLatestItemsHandler(svc))
auth.GET("/Items/Resume", embyResumeItemsHandler(svc))
auth.GET("/Users/:userId/Items/Resume", embyResumeItemsHandler(svc))
auth.GET("/UserItems/Resume", embyResumeItemsHandler(svc))
auth.GET("/Items/:id", embyItemByIDHandler(svc))
auth.GET("/Users/:userId/Items/:id", embyUserItemByIDHandler(svc))
auth.GET("/Shows/:id/Seasons", embyShowSeasonsHandler(svc))
@@ -32,6 +32,8 @@ func registerLowercaseEmbyItemRoutes(auth *gin.RouterGroup, svc *service.Contain
auth.GET("/users/:userId/items/counts", embyItemsCountsHandler(svc))
auth.GET("/items/latest", embyLatestItemsHandler(svc))
auth.GET("/items/resume", embyResumeItemsHandler(svc))
auth.GET("/users/:userId/items/resume", embyResumeItemsHandler(svc))
auth.GET("/useritems/resume", embyResumeItemsHandler(svc))
auth.GET("/items/:id", embyItemByIDHandler(svc))
auth.GET("/users/:userId/items/:id", embyUserItemByIDHandler(svc))
auth.GET("/shows/:id/seasons", embyShowSeasonsHandler(svc))
+11 -1
View File
@@ -41,7 +41,17 @@ func embySessionsHandler(svc *service.Container) gin.HandlerFunc {
"SupportsRemoteControl": true,
}
if itemID != "" && sess.IsPlaying {
row["NowPlayingItem"] = gin.H{"Id": itemID}
nowPlaying := gin.H{"Id": itemID}
if svc.Emby != nil {
if item, _ := svc.Emby.Item(c.Request.Context(), itemID, sess.UserID); item != nil {
for _, key := range []string{"Name", "Type", "RunTimeTicks", "PrimaryImageItemId", "ImageTags", "SeriesName", "SeasonName", "IndexNumber", "ParentIndexNumber"} {
if val, ok := item[key]; ok && val != nil {
nowPlaying[key] = val
}
}
}
}
row["NowPlayingItem"] = nowPlaying
}
out = append(out, row)
}
+70 -45
View File
@@ -7,6 +7,7 @@ import (
"net/http"
"strconv"
"strings"
"sync"
"github.com/gin-gonic/gin"
@@ -16,22 +17,22 @@ import (
)
type createLibraryReq struct {
Name string `json:"name"`
Path string `json:"path"`
Paths []string `json:"paths"`
Roots []service.LibraryRootInput `json:"roots"`
Type string `json:"type"`
CoverURL string `json:"cover_url"`
CreatePerSubfolder bool `json:"create_per_subfolder"`
Name string `json:"name"`
Path string `json:"path"`
Paths []string `json:"paths"`
Roots []service.LibraryRootInput `json:"roots"`
Type string `json:"type"`
CoverURL string `json:"cover_url"`
CreatePerSubfolder bool `json:"create_per_subfolder"`
}
// webLibraryPayload 是 /api/libraries 返回的库条目:本地库与远程 Emby 挂载库
// 统一结构(远程库附加 is_remote_emby / remote_source 只读标记)。
type webLibraryPayload struct {
model.Library
IsRemoteEmby bool `json:"is_remote_emby,omitempty"`
RemoteSource string `json:"remote_source,omitempty"`
Total int64 `json:"total,omitempty"`
IsRemoteEmby bool `json:"is_remote_emby,omitempty"`
RemoteSource string `json:"remote_source,omitempty"`
Total int64 `json:"total,omitempty"`
Cards []service.SeriesCard `json:"cards,omitempty"`
}
@@ -96,22 +97,42 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
// 远程 Emby 挂载库追加在本地库之后。
if svc.EmbyRemote != nil {
if views, err := svc.EmbyRemote.RemoteLibraries(ctx); err == nil {
for _, v := range views {
wl := webLibraryPayload{Library: v.Library, IsRemoteEmby: true, RemoteSource: v.AccountName}
if withPreview {
if acct := svc.EmbyRemote.AccountByID(ctx, v.AccountID); acct != nil {
remotePayloads := make([]webLibraryPayload, len(views))
for i, v := range views {
remotePayloads[i] = webLibraryPayload{Library: v.Library, IsRemoteEmby: true, RemoteSource: v.AccountName}
}
if withPreview && len(views) > 0 {
const maxRemotePreviewWorkers = 6
sem := make(chan struct{}, maxRemotePreviewWorkers)
var wg sync.WaitGroup
for i, v := range views {
i, v := i, v
wg.Add(1)
go func() {
defer wg.Done()
select {
case sem <- struct{}{}:
defer func() { <-sem }()
case <-ctx.Done():
return
}
acct := svc.EmbyRemote.AccountByID(ctx, v.AccountID)
if acct == nil {
return
}
tmpMount := &model.EmbyMount{Base: model.Base{ID: v.MountID}}
itemTypes := remoteLibraryItemTypes(v.CollectionType)
if _, total, err := svc.EmbyRemote.RemoteLibraryMedia(ctx, tmpMount, acct, v.RemoteID, itemTypes, 0, 1); err == nil {
wl.Total = total
remotePayloads[i].Total = total
}
if cards, err := svc.EmbyRemote.RemoteLatestCards(ctx, tmpMount, acct, v.RemoteID, limit); err == nil {
wl.Cards = cards
remotePayloads[i].Cards = cards
}
}
}()
}
out = append(out, wl)
wg.Wait()
}
out = append(out, remotePayloads...)
}
}
c.JSON(http.StatusOK, out)
@@ -171,38 +192,38 @@ func createLibraryHandler(svc *service.Container) gin.HandlerFunc {
}
}
if len(roots) == 0 && strings.TrimSpace(req.Path) != "" {
roots = append(roots, service.LibraryRootInput{Path: req.Path})
}
var l *model.Library
if req.CreatePerSubfolder {
parent := ""
if len(roots) > 0 {
parent = roots[0].Path
} else if strings.TrimSpace(req.Path) != "" {
parent = req.Path
roots = append(roots, service.LibraryRootInput{Path: req.Path})
}
created, err := svc.Media.CreateLibrariesPerSubfolder(c.Request.Context(), parent, req.Type, req.CoverURL)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
uid, _ := c.Get("ctx_user_id")
for i := range created {
lib := &created[i]
svc.Audit.Record(c.Request.Context(), toString(uid), "library.create", lib.ID, c.ClientIP(), lib.Path)
if svc.Watcher != nil {
go func() { _ = svc.Watcher.Refresh(context.Background()) }()
var l *model.Library
if req.CreatePerSubfolder {
parent := ""
if len(roots) > 0 {
parent = roots[0].Path
} else if strings.TrimSpace(req.Path) != "" {
parent = req.Path
}
for _, root := range lib.Roots {
if root.Enabled {
queueLibraryRootScan(svc, lib.ID, root.ID)
created, err := svc.Media.CreateLibrariesPerSubfolder(c.Request.Context(), parent, req.Type, req.CoverURL)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
uid, _ := c.Get("ctx_user_id")
for i := range created {
lib := &created[i]
svc.Audit.Record(c.Request.Context(), toString(uid), "library.create", lib.ID, c.ClientIP(), lib.Path)
if svc.Watcher != nil {
go func() { _ = svc.Watcher.Refresh(context.Background()) }()
}
for _, root := range lib.Roots {
if root.Enabled {
queueLibraryRootScan(svc, lib.ID, root.ID)
}
}
}
c.JSON(http.StatusCreated, gin.H{"libraries": created})
return
}
c.JSON(http.StatusCreated, gin.H{"libraries": created})
return
}
l, err := svc.Media.CreateLibraryWithRootsAndCover(c.Request.Context(), req.Name, req.Type, req.CoverURL, roots)
l, err := svc.Media.CreateLibraryWithRootsAndCover(c.Request.Context(), req.Name, req.Type, req.CoverURL, roots)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -490,6 +511,9 @@ func streamHandler(svc *service.Container) gin.HandlerFunc {
id := c.Param("id")
// 远程 Emby 条目:按挂载代理配置分流——代理走 MMTL 反代,否则 302 直连。
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(id) {
if !enforceScopedPlaybackToken(c, id) {
return
}
mountID, remoteID, _ := service.DecodeEmbyRemoteID(id)
mount, acct, _ := svc.EmbyRemote.ResolveMount(ctx, mountID)
if mount == nil || acct == nil {
@@ -509,6 +533,7 @@ func streamHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
setRedirectNoStoreHeaders(c)
c.Redirect(http.StatusFound, target)
return
}
+25 -4
View File
@@ -19,21 +19,40 @@ import (
"github.com/ShukeBta/MMTL/internal/service"
)
func findMediaForPlaybackEndpoint(c *gin.Context, svc *service.Container, id string) (*model.Media, error) {
ctx := c.Request.Context()
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(id) {
mountID, remoteID, _ := service.DecodeEmbyRemoteID(id)
mount, acct, _ := svc.EmbyRemote.ResolveMount(ctx, mountID)
if mount == nil || acct == nil {
return nil, nil
}
return svc.EmbyRemote.RemoteMediaDetail(ctx, mount, acct, remoteID)
}
return svc.Repo.Media.FindByID(ctx, id)
}
// playbackInfoHandler returns the media row + a `stream_url` the React
// player can hit. Mirrors the Python project's surface.
func playbackInfoHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
m, err := svc.Repo.Media.FindByID(c.Request.Context(), c.Param("id"))
id := c.Param("id")
m, err := findMediaForPlaybackEndpoint(c, svc, id)
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
c.JSON(http.StatusNotFound, gin.H{"error": "media not found"})
return
}
token := externalPlaybackToken(c, svc, m.ID, m.DurationSec)
profileQuery := externalProfileQuery(c)
hlsURL := "/api/hls/" + m.ID + "/index.m3u8?token=" + url.QueryEscape(token) + profileQuery
if service.IsEmbyRemoteID(m.ID) || service.IsStrmMediaRow(m) {
// Emby 远程挂载与 STRM 媒体一样,默认直连播放,不提供转码地址
hlsURL = ""
}
c.JSON(http.StatusOK, gin.H{
"media": m,
"stream_url": "/api/stream/" + m.ID + "?token=" + url.QueryEscape(token) + profileQuery,
"hls_url": "/api/hls/" + m.ID + "/index.m3u8?token=" + url.QueryEscape(token) + profileQuery,
"hls_url": hlsURL,
})
}
}
@@ -68,7 +87,8 @@ func playbackProgressHandler(svc *service.Container) gin.HandlerFunc {
// produce the per-player launch URL.
func externalPlayersHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
m, err := svc.Repo.Media.FindByID(c.Request.Context(), c.Param("id"))
id := c.Param("id")
m, err := findMediaForPlaybackEndpoint(c, svc, id)
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
c.JSON(http.StatusNotFound, gin.H{"error": "media not found"})
return
@@ -93,7 +113,8 @@ func externalPlayersHandler(svc *service.Container) gin.HandlerFunc {
// token query string the external player needs.
func externalURLHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
m, err := svc.Repo.Media.FindByID(c.Request.Context(), c.Param("id"))
id := c.Param("id")
m, err := findMediaForPlaybackEndpoint(c, svc, id)
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
c.JSON(http.StatusNotFound, gin.H{"error": "media not found"})
return
+69
View File
@@ -404,8 +404,77 @@ func newPlaybackScopeTestRouter(t *testing.T) (*gin.Engine, *service.Container,
router := gin.New()
api := router.Group("/api")
api.Use(middleware.AuthRequired(cfg.Secrets.JWTSecret))
api.GET("/playback/:id/info", playbackInfoHandler(svc))
api.GET("/playback/:id/external-url", externalURLHandler(svc))
api.GET("/playback/:id/external-players", externalPlayersHandler(svc))
api.GET("/stream/:id", streamHandler(svc))
api.GET("/hls/:id/index.m3u8", hlsPlaylistHandler(svc))
api.GET("/media/:id/subtitles", listSubtitlesHandler(svc))
return router, svc, cfg.Secrets.JWTSecret
}
func TestPlaybackInfoForSTRMMediaDisablesHLS(t *testing.T) {
router, _, secret := newPlaybackScopeTestRouter(t)
loginToken := signedTestToken(t, secret)
req := httptest.NewRequest(http.MethodGet, "http://nas.local/api/playback/media-1/info", nil)
req.Header.Set("Authorization", "Bearer "+loginToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
var payload struct {
StreamURL string `json:"stream_url"`
HlsURL string `json:"hls_url"`
}
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode: %v", err)
}
if payload.StreamURL == "" {
t.Fatalf("expected non-empty stream_url")
}
if payload.HlsURL != "" {
t.Fatalf("expected empty hls_url for STRM media, got %q", payload.HlsURL)
}
}
func TestHLSPlaylistForRemoteEmbyMediaDisabled(t *testing.T) {
router, svc, secret := newPlaybackScopeTestRouter(t)
svc.EmbyRemote = &service.EmbyRemoteService{}
loginToken := signedTestToken(t, secret)
req := httptest.NewRequest(http.MethodGet, "http://nas.local/api/hls/embyremote~acct1~item1/index.m3u8", nil)
req.Header.Set("Authorization", "Bearer "+loginToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusConflict {
t.Fatalf("status = %d, want %d (409 StatusConflict)", w.Code, http.StatusConflict)
}
}
func TestListSubtitlesForRemoteEmbyMediaReturnsEmptyTracks(t *testing.T) {
router, svc, secret := newPlaybackScopeTestRouter(t)
svc.EmbyRemote = &service.EmbyRemoteService{}
loginToken := signedTestToken(t, secret)
req := httptest.NewRequest(http.MethodGet, "http://nas.local/api/media/embyremote~acct1~item1/subtitles", nil)
req.Header.Set("Authorization", "Bearer "+loginToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 OK", w.Code)
}
var payload struct {
Tracks []any `json:"tracks"`
}
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode: %v", err)
}
if payload.Tracks == nil || len(payload.Tracks) != 0 {
t.Fatalf("expected empty tracks array, got %v", payload.Tracks)
}
}
+5 -1
View File
@@ -51,6 +51,7 @@ func registerAdminStrmRoutes(admin *gin.RouterGroup, svc *service.Container) {
admin.POST("/emby/accounts/:id/full-mount", fullMountEmbyAccountHandler(svc))
admin.GET("/emby/mounts", listEmbyMountsHandler(svc))
admin.POST("/emby/mounts", createEmbyMountsHandler(svc))
admin.PUT("/emby/mounts/reorder", reorderEmbyMountsHandler(svc))
admin.PUT("/emby/mounts/:id", updateEmbyMountHandler(svc))
admin.DELETE("/emby/mounts/:id", deleteEmbyMountHandler(svc))
@@ -93,8 +94,11 @@ func registerAdminStrmRoutes(admin *gin.RouterGroup, svc *service.Container) {
admin.POST("/strm/uploads/:id/retry", retryStrmUploadHandler(svc))
admin.DELETE("/strm/uploads/:id", deleteStrmUploadHandler(svc))
admin.POST("/strm/uploads/batch", batchActionUploadsHandler(svc))
admin.POST("/strm/uploads/cancel-pending", cancelPendingUploadsHandler(svc))
admin.POST("/strm/uploads/clear-done", clearDoneUploadsHandler(svc))
admin.POST("/strm/uploads/clear-finished", clearFinishedUploadsHandler(svc))
admin.POST("/strm/uploads/clear-canceled", clearCanceledUploadsHandler(svc))
admin.POST("/strm/uploads/retry-failed", retryAllFailedUploadsHandler(svc))
admin.POST("/strm/uploads/cancel-pending", cancelPendingUploadsHandler(svc))
}
func registerAdminUserRoutes(admin *gin.RouterGroup, svc *service.Container) {
+13 -2
View File
@@ -13,7 +13,13 @@ import (
func hlsPlaylistHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
m, err := svc.Media.GetMedia(c.Request.Context(), c.Param("id"))
id := c.Param("id")
// 远程 Emby 挂载媒体与 STRM 一样,默认直连播放,不进行转码。
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(id) {
c.JSON(http.StatusConflict, gin.H{"error": "transcode disabled"})
return
}
m, err := svc.Media.GetMedia(c.Request.Context(), id)
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
@@ -43,7 +49,12 @@ func hlsPlaylistHandler(svc *service.Container) gin.HandlerFunc {
func hlsSegmentHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
m, err := svc.Media.GetMedia(c.Request.Context(), c.Param("id"))
id := c.Param("id")
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(id) {
c.JSON(http.StatusConflict, gin.H{"error": "transcode disabled"})
return
}
m, err := svc.Media.GetMedia(c.Request.Context(), id)
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
+33
View File
@@ -504,6 +504,28 @@ func clearCanceledUploadsHandler(svc *service.Container) gin.HandlerFunc {
}
}
func clearDoneUploadsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
n, err := svc.Strm.ClearDoneUploadTasks(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"deleted": n})
}
}
func clearFinishedUploadsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
n, err := svc.Strm.ClearFinishedUploadTasks(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"deleted": n})
}
}
func retryAllFailedDownloadsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
n, err := svc.Strm.RetryAllFailedDownloadTasks(c.Request.Context())
@@ -515,6 +537,17 @@ func retryAllFailedDownloadsHandler(svc *service.Container) gin.HandlerFunc {
}
}
func retryAllFailedUploadsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
n, err := svc.Strm.RetryAllFailedUploadTasks(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"retried": n})
}
}
func cancelPendingDownloadsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
n, err := svc.Strm.CancelPendingDownloadTasks(c.Request.Context())
+13 -10
View File
@@ -50,16 +50,19 @@ func TestStrmAdminRoutesAreRegistered(t *testing.T) {
"GET /api/admin/strm/downloads",
"POST /api/admin/strm/downloads/:id/cancel",
"POST /api/admin/strm/downloads/:id/retry",
"POST /api/admin/strm/downloads/clear-finished",
"POST /api/admin/strm/downloads/clear-canceled",
"POST /api/admin/strm/downloads/retry-failed",
"POST /api/admin/strm/downloads/cancel-pending",
"GET /api/admin/strm/uploads",
"POST /api/admin/strm/uploads/:id/cancel",
"POST /api/admin/strm/uploads/:id/retry",
"POST /api/admin/strm/uploads/cancel-pending",
"POST /api/admin/strm/uploads/clear-canceled",
"GET /api/strm/play/:provider/:file",
"POST /api/admin/strm/downloads/clear-finished",
"POST /api/admin/strm/downloads/clear-canceled",
"POST /api/admin/strm/downloads/retry-failed",
"POST /api/admin/strm/downloads/cancel-pending",
"GET /api/admin/strm/uploads",
"POST /api/admin/strm/uploads/:id/cancel",
"POST /api/admin/strm/uploads/:id/retry",
"POST /api/admin/strm/uploads/clear-done",
"POST /api/admin/strm/uploads/clear-finished",
"POST /api/admin/strm/uploads/clear-canceled",
"POST /api/admin/strm/uploads/retry-failed",
"POST /api/admin/strm/uploads/cancel-pending",
"GET /api/strm/play/:provider/:file",
} {
if !routes[want] {
t.Fatalf("%s route is not registered", want)
+6 -1
View File
@@ -11,7 +11,12 @@ import (
func listSubtitlesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
tracks, err := svc.Subtitle.Discover(c.Request.Context(), c.Param("id"))
id := c.Param("id")
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(id) {
c.JSON(http.StatusOK, gin.H{"tracks": []service.SubtitleTrack{}})
return
}
tracks, err := svc.Subtitle.Discover(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
+13 -1
View File
@@ -125,6 +125,18 @@ func historyContinueHandler(svc *service.Container) gin.HandlerFunc {
for _, r := range rows {
m, ok := mIdx[r.MediaID]
if !ok {
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(r.MediaID) {
mountID, remoteID, _ := service.DecodeEmbyRemoteID(r.MediaID)
if mount, acct, _ := svc.EmbyRemote.ResolveMount(c.Request.Context(), mountID); mount != nil && acct != nil {
if rm, err := svc.EmbyRemote.RemoteMediaDetail(c.Request.Context(), mount, acct, remoteID); err == nil && rm != nil {
out = append(out, gin.H{
"history": r,
"media": *rm,
})
continue
}
}
}
continue
}
out = append(out, gin.H{
@@ -163,7 +175,7 @@ func historyDeleteHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": "status must be completed or incomplete"})
return
}
res := q.Unscoped().Delete(&model.PlaybackHistory{})
res := q.Unscoped().Delete(&model.PlaybackHistory{})
if err := res.Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
+9 -8
View File
@@ -9,11 +9,12 @@ package model
// EmbyMount 是远程 Emby 服务器上一个媒体库(View)的挂载配置。
type EmbyMount struct {
Base
AccountID string `gorm:"size:36;index" json:"account_id"` // StrmAccount.ID(provider=emby_remote)
RemoteViewID string `gorm:"size:128" json:"remote_view_id"` // 远程 Emby 的 View Id
RemoteViewName string `gorm:"size:255" json:"remote_view_name"` // 远程媒体库原名(展示冗余)
CollectionType string `gorm:"size:32" json:"collection_type"` // movies / tvshows / music ...
Name string `gorm:"size:255" json:"name,omitempty"` // 覆盖显示名(可选,默认「账号 · 库名」)
ProxyPlay bool `gorm:"default:false" json:"proxy_play"` // 该挂载播放流量是否经 MMTL 反向代理
Enabled bool `gorm:"default:true" json:"enabled"` // 是否在媒体库中展示
}
AccountID string `gorm:"size:36;index" json:"account_id"` // StrmAccount.ID(provider=emby_remote)
RemoteViewID string `gorm:"size:128" json:"remote_view_id"` // 远程 Emby 的 View Id
RemoteViewName string `gorm:"size:255" json:"remote_view_name"` // 远程媒体库原名(展示冗余)
CollectionType string `gorm:"size:32" json:"collection_type"` // movies / tvshows / music ...
Name string `gorm:"size:255" json:"name,omitempty"` // 覆盖显示名(可选,默认「账号 · 库名」)
SortOrder int `gorm:"default:0;index" json:"sort_order"` // 手动排序用,越小越靠前
ProxyPlay bool `gorm:"default:false" json:"proxy_play"` // 该挂载播放流量是否经 MMTL 反向代理
Enabled bool `gorm:"default:true" json:"enabled"` // 是否在媒体库中展示
}
+2 -2
View File
@@ -8,8 +8,8 @@ type Library struct {
Type string `gorm:"size:16;not null;default:movie" json:"type"` // movie / tv / anime / music
CoverURL string `gorm:"size:1024" json:"cover_url,omitempty"`
Enabled bool `gorm:"default:true" json:"enabled"`
SortOrder int `gorm:"index;default:0" json:"sort_order"` // 手动拖拽排序用,越小越靠前
CarouselEnabled bool `gorm:"default:false" json:"carousel_enabled"` // 是否参与首页海报轮播(默认不参与)
SortOrder int `gorm:"index;default:0" json:"sort_order"` // 手动拖拽排序用,越小越靠前
CarouselEnabled bool `gorm:"default:false" json:"carousel_enabled"` // 是否参与首页海报轮播(默认不参与)
Roots []LibraryRoot `gorm:"foreignKey:LibraryID" json:"roots,omitempty"`
}
+6 -6
View File
@@ -54,10 +54,10 @@ func AllModels() []interface{} {
&StrmAccount{},
&StrmSyncPath{},
&StrmSyncRecord{},
&StrmDownloadTask{},
&StrmUploadTask{},
&StrmDirCache{},
&ScrapeTask{},
&EmbyMount{},
}
&StrmDownloadTask{},
&StrmUploadTask{},
&StrmDirCache{},
&ScrapeTask{},
&EmbyMount{},
}
}
+2 -2
View File
@@ -18,8 +18,8 @@ type ScrapeTask struct {
LibraryName string `gorm:"size:128" json:"library_name"`
MediaTitle string `gorm:"size:255;not null" json:"media_title"`
MediaPath string `gorm:"size:1024;not null" json:"media_path"`
MediaType string `gorm:"size:16" json:"media_type"` // movie / tv / anime / adult
Provider string `gorm:"size:32" json:"provider"` // tmdb / douban / bangumi / thetvdb / metatube
MediaType string `gorm:"size:16" json:"media_type"` // movie / tv / anime / adult
Provider string `gorm:"size:32" json:"provider"` // tmdb / douban / bangumi / thetvdb / metatube
MatchedTitle string `gorm:"size:255" json:"matched_title"`
MatchedYear int `json:"matched_year"`
PosterURL string `gorm:"size:1024" json:"poster_url"`
+11 -12
View File
@@ -38,17 +38,17 @@ type StrmSyncPath struct {
RemotePath string `gorm:"size:1024" json:"remote_path"` // 远端目录:115=目录ID,OpenList/CD2=路径,local=源目录
LocalPath string `gorm:"size:1024" json:"local_path"` // STRM/元数据本地输出目录
// STRM 链接配置(空值继承全局 strm.* 设置)
StrmBaseURL string `gorm:"size:512" json:"strm_base_url"` // 覆盖 strm.base_url
VideoExt string `gorm:"size:512" json:"video_ext"` // 逗号分隔,覆盖 strm.video_ext
MetaExt string `gorm:"size:512" json:"meta_ext"` // 逗号分隔,覆盖 strm.meta_ext
ExcludeName string `gorm:"size:512" json:"exclude_name"` // 逗号分隔,文件名包含即跳过
MinVideoSizeMB int64 `json:"min_video_size_mb"` // 小于该大小(MB)的视频不生成 STRM
AddPath int `json:"add_path"` // STRM 链接 path 参数:1=完整远端路径 2=仅文件名 3=不带
DownloadMeta bool `gorm:"default:true" json:"download_meta"` // 同步时下载元数据文件(nfo/图片/字幕)
UploadMeta bool `json:"upload_meta"` // 同步时把本地元数据上传到远端
DeleteDir bool `json:"delete_dir"` // 清理多余文件时删除空目录
Cron string `gorm:"size:128" json:"cron"` // 5 段 cron 表达式(可选)
EnableCron bool `json:"enable_cron"` // 是否按 Cron 定时同步
StrmBaseURL string `gorm:"size:512" json:"strm_base_url"` // 覆盖 strm.base_url
VideoExt string `gorm:"size:512" json:"video_ext"` // 逗号分隔,覆盖 strm.video_ext
MetaExt string `gorm:"size:512" json:"meta_ext"` // 逗号分隔,覆盖 strm.meta_ext
ExcludeName string `gorm:"size:512" json:"exclude_name"` // 逗号分隔,文件名包含即跳过
MinVideoSizeMB int64 `json:"min_video_size_mb"` // 小于该大小(MB)的视频不生成 STRM
AddPath int `json:"add_path"` // STRM 链接 path 参数:1=完整远端路径 2=仅文件名 3=不带
DownloadMeta bool `gorm:"default:true" json:"download_meta"` // 同步时下载元数据文件(nfo/图片/字幕)
UploadMeta bool `json:"upload_meta"` // 同步时把本地元数据上传到远端
DeleteDir bool `json:"delete_dir"` // 清理多余文件时删除空目录
Cron string `gorm:"size:128" json:"cron"` // 5 段 cron 表达式(可选)
EnableCron bool `json:"enable_cron"` // 是否按 Cron 定时同步
SyncMode string `gorm:"size:32;default:'incremental'" json:"sync_mode"` // 默认同步模式:incremental / full
Enabled bool `gorm:"default:true" json:"enabled"`
LastSyncAt *time.Time `json:"last_sync_at"`
@@ -140,4 +140,3 @@ type StrmDirCache struct {
DirID string `gorm:"size:128;index:idx_strm_dir_cache,priority:2" json:"dir_id"`
Path string `gorm:"size:1024" json:"path"` // 相对根目录的路径
}
+2 -2
View File
@@ -23,8 +23,8 @@ type User struct {
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
// AllowedLibraryIDs 存储管理员为该用户指定的受限可访问媒体库 ID 列表(JSON 字符串)。
// 为空时代表不限制(全库可访问)。
AllowedLibraryIDs string `gorm:"type:text" json:"-"`
AllowedLibraryList []string `gorm:"-" json:"allowed_library_ids,omitempty"`
AllowedLibraryIDs string `gorm:"type:text" json:"-"`
AllowedLibraryList []string `gorm:"-" json:"allowed_library_ids,omitempty"`
// ExpiredAt is the account expiry time. Nil means the account never
// expires. When set and in the past, the account is treated as expired
// (login blocked) until an admin or a redemption code renews it.
+39 -5
View File
@@ -15,7 +15,14 @@ type EmbyMountRepository struct{ db *gorm.DB }
func (r *EmbyMountRepository) Create(ctx context.Context, m *model.EmbyMount) error {
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Create(m).Error
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if m != nil && m.SortOrder == 0 {
var maxSort int
_ = tx.Model(&model.EmbyMount{}).Select("COALESCE(MAX(sort_order), -1)").Scan(&maxSort)
m.SortOrder = maxSort + 1
}
return tx.Create(m).Error
})
})
}
@@ -27,7 +34,17 @@ func (r *EmbyMountRepository) CreateInBatches(ctx context.Context, mounts []*mod
batchSize = 50
}
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).CreateInBatches(mounts, batchSize).Error
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var maxSort int
_ = tx.Model(&model.EmbyMount{}).Select("COALESCE(MAX(sort_order), -1)").Scan(&maxSort)
for _, m := range mounts {
if m != nil && m.SortOrder == 0 {
maxSort++
m.SortOrder = maxSort
}
}
return tx.CreateInBatches(mounts, batchSize).Error
})
})
}
@@ -45,16 +62,33 @@ func (r *EmbyMountRepository) FindByID(ctx context.Context, id string) (*model.E
func (r *EmbyMountRepository) List(ctx context.Context) ([]model.EmbyMount, error) {
var rows []model.EmbyMount
err := r.db.WithContext(ctx).Order("created_at desc").Find(&rows).Error
err := r.db.WithContext(ctx).Order("sort_order asc, created_at asc").Find(&rows).Error
return rows, err
}
func (r *EmbyMountRepository) ListByAccountID(ctx context.Context, accountID string) ([]model.EmbyMount, error) {
var rows []model.EmbyMount
err := r.db.WithContext(ctx).Where("account_id = ?", accountID).Order("created_at asc").Find(&rows).Error
err := r.db.WithContext(ctx).Where("account_id = ?", accountID).Order("sort_order asc, created_at asc").Find(&rows).Error
return rows, err
}
func (r *EmbyMountRepository) SetSortOrder(ctx context.Context, ids []string) error {
if len(ids) == 0 {
return nil
}
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for i, id := range ids {
if err := tx.Model(&model.EmbyMount{}).Where("id = ?", id).
Update("sort_order", i).Error; err != nil {
return err
}
}
return nil
})
})
}
func (r *EmbyMountRepository) CountByAccountID(ctx context.Context, accountID string) (int64, error) {
var count int64
err := r.db.WithContext(ctx).Model(&model.EmbyMount{}).Where("account_id = ?", accountID).Count(&count).Error
@@ -90,4 +124,4 @@ func (r *EmbyMountRepository) DeleteByAccountID(ctx context.Context, accountID s
return res.Error
})
return count, err
}
}
+74
View File
@@ -0,0 +1,74 @@
package repository
import (
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"github.com/ShukeBta/MMTL/internal/database"
"github.com/ShukeBta/MMTL/internal/model"
)
func TestEmbyMountSortOrderAndReorder(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := database.AutoMigrate(db); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := New(db)
ctx := t.Context()
// 1. Create mounts and verify auto-assigned sort_order
m1 := &model.EmbyMount{AccountID: "acct-1", RemoteViewID: "view-1", Name: "Mount 1"}
m2 := &model.EmbyMount{AccountID: "acct-1", RemoteViewID: "view-2", Name: "Mount 2"}
m3 := &model.EmbyMount{AccountID: "acct-1", RemoteViewID: "view-3", Name: "Mount 3"}
if err := repos.EmbyMount.Create(ctx, m1); err != nil {
t.Fatalf("create m1: %v", err)
}
if err := repos.EmbyMount.Create(ctx, m2); err != nil {
t.Fatalf("create m2: %v", err)
}
if err := repos.EmbyMount.Create(ctx, m3); err != nil {
t.Fatalf("create m3: %v", err)
}
if m1.SortOrder >= m2.SortOrder || m2.SortOrder >= m3.SortOrder {
t.Fatalf("expected ascending sort order on create: m1=%d, m2=%d, m3=%d",
m1.SortOrder, m2.SortOrder, m3.SortOrder)
}
// 2. Query list and verify initial order
list, err := repos.EmbyMount.List(ctx)
if err != nil {
t.Fatalf("list mounts: %v", err)
}
if len(list) != 3 || list[0].ID != m1.ID || list[1].ID != m2.ID || list[2].ID != m3.ID {
t.Fatalf("unexpected list order: %+v", list)
}
// 3. Reorder: m3, m1, m2
if err := repos.EmbyMount.SetSortOrder(ctx, []string{m3.ID, m1.ID, m2.ID}); err != nil {
t.Fatalf("SetSortOrder failed: %v", err)
}
// 4. Query list again and verify updated order
reordered, err := repos.EmbyMount.List(ctx)
if err != nil {
t.Fatalf("list mounts after reorder: %v", err)
}
if len(reordered) != 3 {
t.Fatalf("expected 3 mounts, got %d", len(reordered))
}
if reordered[0].ID != m3.ID || reordered[1].ID != m1.ID || reordered[2].ID != m2.ID {
t.Fatalf("expected order [m3, m1, m2], got: %s, %s, %s",
reordered[0].ID, reordered[1].ID, reordered[2].ID)
}
if reordered[0].SortOrder != 0 || reordered[1].SortOrder != 1 || reordered[2].SortOrder != 2 {
t.Fatalf("unexpected sort orders: %d, %d, %d",
reordered[0].SortOrder, reordered[1].SortOrder, reordered[2].SortOrder)
}
}
+1 -1
View File
@@ -23,7 +23,7 @@ func (r *FavoriteRepository) Toggle(ctx context.Context, userID, mediaID string)
if err != nil {
return false, err
}
return false, r.db.WithContext(ctx).Unscoped().Delete(&f).Error
return false, r.db.WithContext(ctx).Unscoped().Delete(&f).Error
}
// ListByUser returns all favourite media IDs for a user.
+87 -2
View File
@@ -3,6 +3,8 @@ package repository
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"gorm.io/gorm"
@@ -87,6 +89,18 @@ func (r *MediaRepository) ListByLibraryFiltered(ctx context.Context, libraryID s
}
func (r *MediaRepository) ListByLibrariesFiltered(ctx context.Context, libraryIDs []string, offset, limit int, filter MediaQueryFilter) ([]model.Media, int64, error) {
items, total, err := r.listByLibrariesFiltered(ctx, libraryIDs, offset, limit, filter, true)
return items, total, err
}
// ListByLibrariesFilteredNoCount skips the COUNT query when the caller already
// knows totals or only needs a bounded slice (e.g. home-page previews).
func (r *MediaRepository) ListByLibrariesFilteredNoCount(ctx context.Context, libraryIDs []string, offset, limit int, filter MediaQueryFilter) ([]model.Media, error) {
items, _, err := r.listByLibrariesFiltered(ctx, libraryIDs, offset, limit, filter, false)
return items, err
}
func (r *MediaRepository) listByLibrariesFiltered(ctx context.Context, libraryIDs []string, offset, limit int, filter MediaQueryFilter, withCount bool) ([]model.Media, int64, error) {
var items []model.Media
var total int64
if len(libraryIDs) == 0 {
@@ -99,8 +113,10 @@ func (r *MediaRepository) ListByLibrariesFiltered(ctx context.Context, libraryID
q = q.Where("library_id IN ?", libraryIDs)
}
q = applyMediaQueryFilter(q, filter)
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
if withCount {
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
}
// 多级排序消除"随机"观感:
// 1. release_date desc — 精确上映/首播日期新→旧
@@ -114,6 +130,75 @@ func (r *MediaRepository) ListByLibrariesFiltered(ctx context.Context, libraryID
return items, total, err
}
type rankedMediaRow struct {
model.Media
MmtlRN int `gorm:"column:mmtl_rn"`
}
// ListRecentByLibraries returns up to perLibrary recent items for each library
// in a single query using a window function (avoids N+1 on home preview).
func (r *MediaRepository) ListRecentByLibraries(ctx context.Context, libraryIDs []string, perLibrary int, filter MediaQueryFilter) (map[string][]model.Media, error) {
out := make(map[string][]model.Media, len(libraryIDs))
if len(libraryIDs) == 0 || perLibrary <= 0 {
return out, nil
}
var libClause string
var args []interface{}
if len(libraryIDs) == 1 {
libClause = "library_id = ?"
args = append(args, libraryIDs[0])
} else {
libClause = "library_id IN ?"
args = append(args, libraryIDs)
}
where := "deleted_at IS NULL AND " + libClause
if filterSQL, filterArgs := mediaQueryFilterSQL(filter); filterSQL != "" {
where += " AND " + filterSQL
args = append(args, filterArgs...)
}
args = append(args, perLibrary)
sql := fmt.Sprintf(`
SELECT * FROM (
SELECT *, ROW_NUMBER() OVER (
PARTITION BY library_id
ORDER BY release_date DESC, year DESC, updated_at DESC, created_at DESC, id DESC
) AS mmtl_rn
FROM media
WHERE %s
) ranked
WHERE mmtl_rn <= ?
`, where)
var rows []rankedMediaRow
if err := r.db.WithContext(ctx).Raw(sql, args...).Scan(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
out[row.LibraryID] = append(out[row.LibraryID], row.Media)
}
return out, nil
}
func mediaQueryFilterSQL(filter MediaQueryFilter) (string, []interface{}) {
var parts []string
var args []interface{}
if !filter.IncludeNSFW {
parts = append(parts, "nsfw = ?")
args = append(args, false)
}
if len(filter.HiddenLibraryIDs) > 0 {
parts = append(parts, "library_id NOT IN ?")
args = append(args, filter.HiddenLibraryIDs)
}
if len(filter.AllowedLibraryIDs) > 0 {
parts = append(parts, "library_id IN ?")
args = append(args, filter.AllowedLibraryIDs)
}
return strings.Join(parts, " AND "), args
}
type libraryCountRow struct {
LibraryID string `gorm:"column:library_id"`
Total int64 `gorm:"column:total"`
@@ -0,0 +1,72 @@
package repository
import (
"fmt"
"testing"
"time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"github.com/ShukeBta/MMTL/internal/database"
"github.com/ShukeBta/MMTL/internal/model"
)
func TestListRecentByLibraries(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := database.AutoMigrate(db); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := New(db)
lib1 := model.Library{Name: "电影", Path: "/media/movies", Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib1); err != nil {
t.Fatal(err)
}
lib2 := model.Library{Name: "动漫", Path: "/media/anime", Type: "anime", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib2); err != nil {
t.Fatal(err)
}
now := time.Date(2026, 7, 2, 12, 0, 0, 0, time.UTC)
var rows []model.Media
for i := 1; i <= 5; i++ {
rows = append(rows, model.Media{
Base: model.Base{ID: fmt.Sprintf("movie-%02d", i), CreatedAt: now.Add(time.Duration(i) * time.Hour)},
LibraryID: lib1.ID,
Title: fmt.Sprintf("电影%d", i),
Path: fmt.Sprintf("/media/movies/电影%d/movie%d.mp4", i, i),
})
}
for i := 1; i <= 8; i++ {
rows = append(rows, model.Media{
Base: model.Base{ID: fmt.Sprintf("anime-ep-%02d", i), CreatedAt: now.Add(time.Duration(i) * time.Minute)},
LibraryID: lib2.ID,
Title: fmt.Sprintf("某动漫 第%d集", i),
Path: fmt.Sprintf("/media/anime/某动漫/Season 01/某动漫.S01E%02d.mp4", i),
SeasonNum: 1,
EpisodeNum: i,
})
}
if err := repos.DB.Create(&rows).Error; err != nil {
t.Fatal(err)
}
filter := MediaQueryFilter{IncludeNSFW: true}
got, err := repos.Media.ListRecentByLibraries(t.Context(), []string{lib1.ID, lib2.ID}, 3, filter)
if err != nil {
t.Fatalf("ListRecentByLibraries failed: %v", err)
}
if len(got[lib1.ID]) != 3 {
t.Fatalf("lib1 recent count = %d, want 3", len(got[lib1.ID]))
}
if len(got[lib2.ID]) != 3 {
t.Fatalf("lib2 recent count = %d, want 3", len(got[lib2.ID]))
}
if got[lib1.ID][0].ID != "movie-05" {
t.Fatalf("lib1 newest = %q, want movie-05", got[lib1.ID][0].ID)
}
}
+4 -4
View File
@@ -30,11 +30,11 @@ type Container struct {
StrmSyncPath *StrmSyncPathRepository
StrmSyncRecord *StrmSyncRecordRepository
StrmDownload *StrmDownloadTaskRepository
StrmUpload *StrmUploadTaskRepository
StrmDirCache *StrmDirCacheRepository
ScrapeTask *ScrapeTaskRepository
StrmUpload *StrmUploadTaskRepository
StrmDirCache *StrmDirCacheRepository
ScrapeTask *ScrapeTaskRepository
EmbyMount *EmbyMountRepository
}
}
// New 将每个 repository 连接到单个 *gorm.DB。
func New(db *gorm.DB) *Container {
+44 -2
View File
@@ -675,6 +675,29 @@ func (r *StrmUploadTaskRepository) CancelBatch(ctx context.Context, ids []string
return count, err
}
// ClearDone 清空全部已完成上传任务。
func (r *StrmUploadTaskRepository) ClearDone(ctx context.Context) (int64, error) {
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Unscoped().Where("status = ?", model.StrmTaskDone).Delete(&model.StrmUploadTask{})
count = res.RowsAffected
return res.Error
})
return count, err
}
// ClearFinished 清空全部已完成与失败上传任务(包括已完成、失败及取消)。
func (r *StrmUploadTaskRepository) ClearFinished(ctx context.Context) (int64, error) {
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Unscoped().Where("status IN ?", []string{model.StrmTaskDone, model.StrmTaskFailed, model.StrmTaskCanceled}).
Delete(&model.StrmUploadTask{})
count = res.RowsAffected
return res.Error
})
return count, err
}
// ClearCanceled 清空全部已取消上传任务。
func (r *StrmUploadTaskRepository) ClearCanceled(ctx context.Context) (int64, error) {
var count int64
@@ -686,6 +709,27 @@ func (r *StrmUploadTaskRepository) ClearCanceled(ctx context.Context) (int64, er
return count, err
}
// RetryAllFailed 把所有失败任务重置回待处理,清空错误与重试计数。
func (r *StrmUploadTaskRepository) RetryAllFailed(ctx context.Context) (int64, error) {
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Model(&model.StrmUploadTask{}).
Where("status = ?", model.StrmTaskFailed).
Updates(map[string]any{
"status": model.StrmTaskPending,
"error": "",
"retry_count": 0,
"next_try_at": nil,
"started_at": nil,
"finished_at": nil,
"updated_at": time.Now(),
})
count = res.RowsAffected
return res.Error
})
return count, err
}
// CancelPending 批量取消所有排队中和进行中的任务。
func (r *StrmUploadTaskRepository) CancelPending(ctx context.Context) (int64, error) {
now := time.Now()
@@ -777,5 +821,3 @@ func (r *StrmDirCacheRepository) DeleteBySyncPathID(ctx context.Context, syncPat
return r.db.WithContext(ctx).Unscoped().Where("sync_path_id = ?", syncPathID).Delete(&model.StrmDirCache{}).Error
})
}
+96
View File
@@ -4,8 +4,11 @@
package service
import (
"errors"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
@@ -42,3 +45,96 @@ func walkAndPrune(root string, cutoff time.Time) error {
}
return nil
}
// PruneImageCacheResult holds stats from an image cache prune operation.
type PruneImageCacheResult struct {
TotalFilesBefore int
TotalBytesBefore int64
DeletedFiles int
FreedBytes int64
RemainingBytes int64
}
type imageCacheFileEntry struct {
path string
size int64
modTime time.Time
}
// PruneImageCache scans imagesDir for cached image files. If the total disk usage
// exceeds maxSizeBytes, it removes files starting from the oldest (by ModTime)
// until disk usage falls to or below targetSizeBytes (80% of maxSizeBytes).
//
// In-flight temporary files (*.tmp) are skipped to avoid corrupting concurrent writes.
// Empty subdirectories left behind are best-effort removed.
func PruneImageCache(imagesDir string, maxSizeBytes int64) (PruneImageCacheResult, error) {
var result PruneImageCacheResult
if imagesDir == "" || maxSizeBytes <= 0 {
return result, nil
}
if _, err := os.Stat(imagesDir); err != nil {
return result, nil
}
var (
dirs []string
entries []imageCacheFileEntry
)
_ = filepath.Walk(imagesDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if info.IsDir() {
if path != imagesDir {
dirs = append(dirs, path)
}
return nil
}
// Skip temporary files created during image download.
name := info.Name()
if strings.HasSuffix(name, ".tmp") || strings.HasPrefix(name, "img-") && strings.Contains(name, ".tmp") {
return nil
}
size := info.Size()
result.TotalFilesBefore++
result.TotalBytesBefore += size
entries = append(entries, imageCacheFileEntry{
path: path,
size: size,
modTime: info.ModTime(),
})
return nil
})
result.RemainingBytes = result.TotalBytesBefore
if result.TotalBytesBefore <= maxSizeBytes {
return result, nil
}
// High/Low watermark: prune down to 80% of max size to leave headroom
// and prevent disk thrashing on consecutive writes.
targetSizeBytes := maxSizeBytes * 80 / 100
sort.Slice(entries, func(i, j int) bool {
return entries[i].modTime.Before(entries[j].modTime)
})
for _, entry := range entries {
if result.RemainingBytes <= targetSizeBytes {
break
}
if err := os.Remove(entry.path); err == nil || errors.Is(err, os.ErrNotExist) {
result.DeletedFiles++
result.FreedBytes += entry.size
result.RemainingBytes -= entry.size
}
}
// Clean up emptied subdirectories from deepest to shallowest.
for i := len(dirs) - 1; i >= 0; i-- {
_ = os.Remove(dirs[i])
}
return result, nil
}
+162
View File
@@ -0,0 +1,162 @@
package service
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"go.uber.org/zap"
)
func TestPruneImageCache_UnderLimit(t *testing.T) {
dir := t.TempDir()
file1 := filepath.Join(dir, "img1")
file2 := filepath.Join(dir, "img2")
if err := os.WriteFile(file1, make([]byte, 100), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(file2, make([]byte, 200), 0o600); err != nil {
t.Fatal(err)
}
// Max limit is 500 bytes, total is 300 bytes -> no prune
res, err := PruneImageCache(dir, 500)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if res.DeletedFiles != 0 {
t.Fatalf("expected 0 deleted files, got %d", res.DeletedFiles)
}
if res.TotalFilesBefore != 2 || res.TotalBytesBefore != 300 || res.RemainingBytes != 300 {
t.Fatalf("unexpected stats: %+v", res)
}
}
func TestPruneImageCache_OverLimitLRU(t *testing.T) {
dir := t.TempDir()
now := time.Now()
// Create 4 files of 100 bytes each, with distinct mtime
fOldest := filepath.Join(dir, "oldest")
fMidOld := filepath.Join(dir, "mid_old")
fMidNew := filepath.Join(dir, "mid_new")
fNewest := filepath.Join(dir, "newest")
for _, f := range []string{fOldest, fMidOld, fMidNew, fNewest} {
if err := os.WriteFile(f, make([]byte, 100), 0o600); err != nil {
t.Fatal(err)
}
}
_ = os.Chtimes(fOldest, now.Add(-4*time.Hour), now.Add(-4*time.Hour))
_ = os.Chtimes(fMidOld, now.Add(-3*time.Hour), now.Add(-3*time.Hour))
_ = os.Chtimes(fMidNew, now.Add(-2*time.Hour), now.Add(-2*time.Hour))
_ = os.Chtimes(fNewest, now.Add(-1*time.Hour), now.Add(-1*time.Hour))
// Total = 400 bytes. Max limit = 300 bytes.
// Target = 300 * 80 / 100 = 240 bytes.
// Deleting oldest (100) brings total to 300 (> 240).
// Deleting mid_old (100) brings total to 200 (<= 240).
// Total deleted = 2 files (200 bytes), remaining = 200 bytes.
res, err := PruneImageCache(dir, 300)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if res.DeletedFiles != 2 {
t.Fatalf("expected 2 deleted files, got %d", res.DeletedFiles)
}
if res.FreedBytes != 200 {
t.Fatalf("expected 200 freed bytes, got %d", res.FreedBytes)
}
if res.RemainingBytes != 200 {
t.Fatalf("expected 200 remaining bytes, got %d", res.RemainingBytes)
}
// Verify oldest and mid_old were deleted, mid_new and newest still exist
if _, err := os.Stat(fOldest); !os.IsNotExist(err) {
t.Fatalf("expected oldest file to be deleted, got err=%v", err)
}
if _, err := os.Stat(fMidOld); !os.IsNotExist(err) {
t.Fatalf("expected mid_old file to be deleted, got err=%v", err)
}
if _, err := os.Stat(fMidNew); err != nil {
t.Fatalf("expected mid_new file to exist, got err=%v", err)
}
if _, err := os.Stat(fNewest); err != nil {
t.Fatalf("expected newest file to exist, got err=%v", err)
}
}
func TestPruneImageCache_SkipsTmpFiles(t *testing.T) {
dir := t.TempDir()
fTmp := filepath.Join(dir, "img-123.tmp")
fImg := filepath.Join(dir, "cached_img")
if err := os.WriteFile(fTmp, make([]byte, 500), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(fImg, make([]byte, 100), 0o600); err != nil {
t.Fatal(err)
}
// Limit is 200 bytes. fTmp (500) is ignored, only fImg (100) is counted <= 200.
res, err := PruneImageCache(dir, 200)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if res.DeletedFiles != 0 {
t.Fatalf("expected 0 deleted files, got %d", res.DeletedFiles)
}
if _, err := os.Stat(fTmp); err != nil {
t.Fatalf("expected tmp file to remain untouched, got %v", err)
}
}
func TestPruneImageCache_ZeroOrNegativeLimit(t *testing.T) {
dir := t.TempDir()
f := filepath.Join(dir, "img")
if err := os.WriteFile(f, make([]byte, 100), 0o600); err != nil {
t.Fatal(err)
}
res, err := PruneImageCache(dir, 0)
if err != nil || res.DeletedFiles != 0 {
t.Fatalf("expected no-op for 0 limit, got %+v, err=%v", res, err)
}
res, err = PruneImageCache(dir, -10)
if err != nil || res.DeletedFiles != 0 {
t.Fatalf("expected no-op for negative limit, got %+v, err=%v", res, err)
}
}
func TestSchedulerJobCleanImageCache(t *testing.T) {
cacheRoot := t.TempDir()
imagesDir := filepath.Join(cacheRoot, "images")
if err := os.MkdirAll(imagesDir, 0o750); err != nil {
t.Fatal(err)
}
f := filepath.Join(imagesDir, "old_poster")
if err := os.WriteFile(f, make([]byte, 2*1024*1024), 0o600); err != nil {
t.Fatal(err)
}
scheduler := NewSchedulerService(zap.NewNop(), nil, nil, nil, nil, nil, cacheRoot)
// Set limit to 1MB; our file is 2MB -> should be pruned
scheduler.SetImagesMaxSizeMBProvider(func() int {
return 1
})
if err := scheduler.jobCleanImageCache(context.Background()); err != nil {
t.Fatalf("jobCleanImageCache failed: %v", err)
}
if _, err := os.Stat(f); !os.IsNotExist(err) {
t.Fatalf("expected file to be pruned, got err=%v", err)
}
}
+5 -5
View File
@@ -38,11 +38,11 @@ var ErrUnsupported = errors.New("unsupported cloud provider")
// FileEntry is one item in a cloud directory listing.
type FileEntry struct {
ID string `json:"id"` // provider-native file id
Name string `json:"name"`
IsDir bool `json:"is_dir"`
Size int64 `json:"size"`
MTime int64 `json:"mtime,omitempty"`
ID string `json:"id"` // provider-native file id
Name string `json:"name"`
IsDir bool `json:"is_dir"`
Size int64 `json:"size"`
MTime int64 `json:"mtime,omitempty"`
// PickCode is 115-specific; other providers use ID directly.
PickCode string `json:"pick_code,omitempty"`
}
+7 -7
View File
@@ -184,12 +184,12 @@ func (p *embyProvider) ensureToken(ctx context.Context) (string, error) {
// embyItemSummary 目录浏览所需的最小 Emby 条目字段。
type embyItemSummary struct {
Id string `json:"Id"`
Name string `json:"Name"`
Type string `json:"Type"`
IsFolder bool `json:"IsFolder"`
ChildCount int `json:"ChildCount"`
RunTimeTicks int64 `json:"RunTimeTicks"`
Id string `json:"Id"`
Name string `json:"Name"`
Type string `json:"Type"`
IsFolder bool `json:"IsFolder"`
ChildCount int `json:"ChildCount"`
RunTimeTicks int64 `json:"RunTimeTicks"`
}
type embyItemListResponse struct {
@@ -243,4 +243,4 @@ func (p *embyProvider) Resolve(ctx context.Context, fileRef string) (*DirectLink
u := p.apiBase() + "/Videos/" + url.PathEscape(fileRef) + "/stream"
u += "?api_key=" + url.QueryEscape(token) + "&Static=true&MediaSourceId=" + url.QueryEscape(fileRef)
return &DirectLink{URL: u, Headers: map[string]string{"X-Emby-Token": token}, Proxy: p.proxyPlay}, nil
}
}
+8 -8
View File
@@ -59,8 +59,8 @@ func TestEmbyProviderListViewsAndChildren(t *testing.T) {
defer srv.Close()
p, err := New(TypeEmbyRemote, map[string]any{
"url": srv.URL,
"api_key": "fixed-token",
"url": srv.URL,
"api_key": "fixed-token",
"remote_user_id": "user-9",
}, srv.Client())
if err != nil {
@@ -87,8 +87,8 @@ func TestEmbyProviderResolveDirectURLByDefault(t *testing.T) {
defer srv.Close()
p, err := New(TypeEmbyRemote, map[string]any{
"url": srv.URL,
"api_key": "fixed-token",
"url": srv.URL,
"api_key": "fixed-token",
"remote_user_id": "user-9",
}, srv.Client())
if err != nil {
@@ -115,10 +115,10 @@ func TestEmbyProviderResolveProxyWhenConfigured(t *testing.T) {
defer srv.Close()
p, err := New(TypeEmbyRemote, map[string]any{
"url": srv.URL,
"api_key": "fixed-token",
"url": srv.URL,
"api_key": "fixed-token",
"remote_user_id": "user-9",
"proxy_play": "true",
"proxy_play": "true",
}, srv.Client())
if err != nil {
t.Fatal(err)
@@ -133,4 +133,4 @@ func TestEmbyProviderResolveProxyWhenConfigured(t *testing.T) {
if link.URL == "" {
t.Fatal("proxy link must still carry the remote URL")
}
}
}
+1 -1
View File
@@ -62,4 +62,4 @@ func xorDecode(hexStr string) string {
func dandanplaySignature(appID, appSecret string, ts int64, path string) string {
sum := sha256.Sum256([]byte(appID + strconv.FormatInt(ts, 10) + path + appSecret))
return base64.StdEncoding.EncodeToString(sum[:])
}
}
+1 -1
View File
@@ -81,4 +81,4 @@ func TestDanmakuCredentialsSelection(t *testing.T) {
require.False(t, ok)
require.Empty(t, id)
require.Empty(t, key)
}
}
+162 -60
View File
@@ -278,69 +278,171 @@ func TestDanmakuSameBase(t *testing.T) {
require.False(t, sameDanmakuBase("", "https://api.dandanplay.net"))
}
// fetchCommentWithFallback:配置源与官方同源时不重复请求;
// 全失败时带出最后一跳错误。
func TestDanmakuFetchCommentWithFallback(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
t.Cleanup(srv.Close)
// fetchCommentWithFallback:配置源与官方同源时不重复请求;
// 全失败时带出最后一跳错误。
func TestDanmakuFetchCommentWithFallback(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
t.Cleanup(srv.Close)
svc := newDanmakuTestService(t)
ctx := context.Background()
raw, st, err := svc.fetchCommentWithFallback(ctx, srv.URL, srv.URL, "25484")
require.Error(t, err)
require.Empty(t, raw)
require.Equal(t, "auto", st)
}
svc := newDanmakuTestService(t)
ctx := context.Background()
raw, st, err := svc.fetchCommentWithFallback(ctx, srv.URL, srv.URL, "25484")
require.Error(t, err)
require.Empty(t, raw)
require.Equal(t, "auto", st)
}
// 视频即便能命中 Hash 自动识别,当用户传入手动搜索关键词时应跳过 Hash 匹配,走关键词搜索。
func TestDanmakuFetchHashMatchSkippedOnManualKeyword(t *testing.T) {
videoPath, _ := writeDanmakuTestVideo(t, "测试动画.第01话.mkv")
// 视频即便能命中 Hash 自动识别,当用户传入手动搜索关键词时应跳过 Hash 匹配,走关键词搜索。
func TestDanmakuFetchHashMatchSkippedOnManualKeyword(t *testing.T) {
videoPath, _ := writeDanmakuTestVideo(t, "测试动画.第01话.mkv")
// 官方服务同时提供 match 和 search:
// match 会返回 episodeId=25484(动画A)
// search 会根据关键词返回 episodeId=99999(动画B)
mux := http.NewServeMux()
var matchCalled bool
mux.HandleFunc("/api/v2/match", func(w http.ResponseWriter, r *http.Request) {
matchCalled = true
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"success":true,"isMatched":true,"matches":[{"episodeId":25484,"animeId":1001,"animeTitle":"自动识别动画A","episodeTitle":"第1话"}]}`)
})
mux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"hasMore":false,"animes":[{"animeId":2002,"animeTitle":"手动搜索动画B","episodes":[{"episodeId":99999,"episodeTitle":"第1话"}]}]}`)
})
mux.HandleFunc("/api/v2/comment/25484", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
fmt.Fprint(w, `<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">自动识别弹幕</d></i>`)
})
mux.HandleFunc("/api/v2/comment/99999", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
fmt.Fprint(w, `<?xml version="1.0"?><i><d p="0.5,1,16777215,user2">手动搜索弹幕</d></i>`)
})
official := httptest.NewServer(mux)
t.Cleanup(official.Close)
overrideDanmakuOfficialBase(t, official.URL)
// 官方服务同时提供 match 和 search:
// match 会返回 episodeId=25484(动画A)
// search 会根据关键词返回 episodeId=99999(动画B)
mux := http.NewServeMux()
var matchCalled bool
mux.HandleFunc("/api/v2/match", func(w http.ResponseWriter, r *http.Request) {
matchCalled = true
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"success":true,"isMatched":true,"matches":[{"episodeId":25484,"animeId":1001,"animeTitle":"自动识别动画A","episodeTitle":"第1话"}]}`)
})
mux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"hasMore":false,"animes":[{"animeId":2002,"animeTitle":"手动搜索动画B","episodes":[{"episodeId":99999,"episodeTitle":"第1话"}]}]}`)
})
mux.HandleFunc("/api/v2/comment/25484", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
fmt.Fprint(w, `<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">自动识别弹幕</d></i>`)
})
mux.HandleFunc("/api/v2/comment/99999", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
fmt.Fprint(w, `<?xml version="1.0"?><i><d p="0.5,1,16777215,user2">手动搜索弹幕</d></i>`)
})
official := httptest.NewServer(mux)
t.Cleanup(official.Close)
overrideDanmakuOfficialBase(t, official.URL)
svc := newDanmakuTestService(t)
ctx := context.Background()
seedDanmakuVideoMedia(t, svc, "mManual", "自动识别动画A", videoPath, 32000, 1)
svc := newDanmakuTestService(t)
ctx := context.Background()
seedDanmakuVideoMedia(t, svc, "mManual", "自动识别动画A", videoPath, 32000, 1)
// 1) 默认自动识别:命中 Hash 识别
resAuto, err := svc.Fetch(ctx, "mManual", "", "")
require.NoError(t, err)
require.True(t, matchCalled)
require.Equal(t, "hash", resAuto.MatchMode)
require.Equal(t, int64(25484), resAuto.EpisodeID)
require.Contains(t, resAuto.Raw, "自动识别弹幕")
// 1) 默认自动识别:命中 Hash 识别
resAuto, err := svc.Fetch(ctx, "mManual", "", "")
require.NoError(t, err)
require.True(t, matchCalled)
require.Equal(t, "hash", resAuto.MatchMode)
require.Equal(t, int64(25484), resAuto.EpisodeID)
require.Contains(t, resAuto.Raw, "自动识别弹幕")
// 2) 用户传入手动搜索关键词:跳过 Hash 识别,命中搜索结果动画B
resManual, err := svc.Fetch(ctx, "mManual", "手动搜索动画B", "")
require.NoError(t, err)
require.Equal(t, "search", resManual.MatchMode)
require.Equal(t, int64(99999), resManual.EpisodeID)
require.Equal(t, "手动搜索动画B", resManual.AnimeTitle)
require.Contains(t, resManual.Raw, "手动搜索弹幕")
}
// 2) 用户传入手动搜索关键词:跳过 Hash 识别,命中搜索结果动画B
resManual, err := svc.Fetch(ctx, "mManual", "手动搜索动画B", "")
require.NoError(t, err)
require.Equal(t, "search", resManual.MatchMode)
require.Equal(t, int64(99999), resManual.EpisodeID)
require.Equal(t, "手动搜索动画B", resManual.AnimeTitle)
require.Contains(t, resManual.Raw, "手动搜索弹幕")
}
// Emby 远程挂载条目:通过伪装 ID 解析出流直链,通过 Range 提取 16MB 前缀计算 hash 并匹配弹幕。
func TestDanmakuFetchEmbyRemoteHashViaDirectLink(t *testing.T) {
content := bytes.Repeat([]byte("emby-remote-video-bytes-9876543210"), 300)
sum := md5.Sum(content)
wantHash := hex.EncodeToString(sum[:])
var gotRange string
var rangeHits int
rangeSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rangeHits++
gotRange = r.Header.Get("Range")
w.Header().Set("Content-Type", "application/octet-stream")
_, _ = w.Write(content)
}))
t.Cleanup(rangeSrv.Close)
var seen string
official := danmakuOfficialServer(t,
`{"success":true,"isMatched":true,"matches":[{"episodeId":25484,"animeId":2001,"animeTitle":"芙莉莲","episodeTitle":"第1话"}]}`,
`<?xml version="1.0"?><i><d p="1.2,1,16777215,user1">Emby远程弹幕命中</d></i>`,
&seen)
overrideDanmakuOfficialBase(t, official.URL)
remoteMediaID := EncodeEmbyRemoteID("mount-123", "remote-item-456")
svc := newDanmakuTestService(t)
svc.SetRemoteMediaResolver(func(_ context.Context, encodedID string) (*model.Media, string, error) {
require.Equal(t, remoteMediaID, encodedID)
return &model.Media{
Base: model.Base{ID: remoteMediaID},
Title: "葬送的芙莉莲",
EpisodeTitle: "第1话",
EpisodeNum: 1,
Path: "/mnt/emby/anime/Frieren/S01E01.mkv",
SizeBytes: int64(len(content)),
DurationSec: 1400,
}, rangeSrv.URL, nil
})
ctx := context.Background()
res, err := svc.Fetch(ctx, remoteMediaID, "", "")
require.NoError(t, err)
require.True(t, res.Enabled)
require.Equal(t, "hash", res.MatchMode)
require.Equal(t, int64(25484), res.EpisodeID)
require.Equal(t, "芙莉莲", res.AnimeTitle)
require.Contains(t, res.Raw, "Emby远程弹幕命中")
require.Contains(t, gotRange, "bytes=0-")
require.Contains(t, seen, `"fileHash":"`+wantHash+`"`)
require.Contains(t, seen, `"fileName":"`+url.QueryEscape("S01E01")+`"`)
require.Equal(t, 1, rangeHits)
// 第二次拉取验证 hashCache 命中,不重复请求 rangeSrv
res2, err := svc.Fetch(ctx, remoteMediaID, "", "")
require.NoError(t, err)
require.Equal(t, "hash", res2.MatchMode)
require.Equal(t, 1, rangeHits)
}
// Emby 远程直链拉取失败时(如网络异常),能平滑降级走番剧原名/标题关键词搜索。
func TestDanmakuFetchEmbyRemoteStreamFailedFallsBackToSearch(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
// 文件名搜索 ep01 时无结果,模拟文件名未匹配
if r.URL.Query().Get("anime") == "ep01" {
fmt.Fprint(w, `{"hasMore":false,"animes":[]}`)
return
}
// 降级到番剧名搜索命中
fmt.Fprint(w, `{"hasMore":false,"animes":[{"animeId":3001,"animeTitle":"降级搜索番剧","episodes":[{"episodeId":7799,"episodeTitle":"第1话"}]}]}`)
})
mux.HandleFunc("/api/v2/comment/7799", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
fmt.Fprint(w, `<?xml version="1.0"?><i><d p="0.8,1,16777215,user1">降级搜索弹幕</d></i>`)
})
official := httptest.NewServer(mux)
t.Cleanup(official.Close)
overrideDanmakuOfficialBase(t, official.URL)
remoteMediaID := EncodeEmbyRemoteID("mount-123", "remote-item-789")
svc := newDanmakuTestService(t)
// 返回一个不存在的流服务地址模拟 Range 拉取失败
svc.SetRemoteMediaResolver(func(_ context.Context, encodedID string) (*model.Media, string, error) {
return &model.Media{
Base: model.Base{ID: remoteMediaID},
Title: "降级搜索番剧",
EpisodeNum: 1,
Path: "/mnt/emby/anime/fallback/ep01.mkv",
DurationSec: 1200,
}, "http://127.0.0.1:1/invalid-stream", nil
})
ctx := context.Background()
res, err := svc.Fetch(ctx, remoteMediaID, "", "")
require.NoError(t, err)
require.True(t, res.Enabled)
require.Equal(t, "search", res.MatchMode)
require.Equal(t, int64(7799), res.EpisodeID)
require.Equal(t, "降级搜索番剧", res.AnimeTitle)
require.Contains(t, res.Raw, "降级搜索弹幕")
}
+176 -83
View File
@@ -94,6 +94,10 @@ type DanmakuEpisode struct {
EpisodeTitle string `json:"episodeTitle"`
}
// DanmakuRemoteMediaResolver resolves an Emby remote pseudo-ID (e.g. embyremote~mount~id)
// into a memory model.Media and a direct stream URL.
type DanmakuRemoteMediaResolver func(ctx context.Context, encodedID string) (*model.Media, string, error)
// DanmakuService fetches danmaku for a media item through the dandanplay
// protocol: match by 16MB-prefix hash, then search for an episode id by the
// video's name, then fetch the comment library XML. The React player parses
@@ -108,6 +112,10 @@ type DanmakuService struct {
// StrmService.ResolvePlay; nil means strm sources are skipped.
strmResolve func(ctx context.Context, provider string, q url.Values) (*StrmPlayResult, error)
// remoteResolve resolves an Emby remote pseudo-ID into *model.Media and
// direct stream URL for range hashing.
remoteResolve DanmakuRemoteMediaResolver
hashCacheMu sync.Mutex
hashCache map[string]string // stamp → 16MB-prefix MD5
}
@@ -136,6 +144,14 @@ func (s *DanmakuService) SetStrmResolver(resolve func(ctx context.Context, provi
}
}
// SetRemoteMediaResolver wires the resolver used to fetch metadata and direct
// stream URLs for Emby remote mounted media.
func (s *DanmakuService) SetRemoteMediaResolver(resolve DanmakuRemoteMediaResolver) {
if s != nil {
s.remoteResolve = resolve
}
}
// Config reads danmaku settings from the runtime settings table.
func (s *DanmakuService) Config(ctx context.Context) DanmakuRenderConfig {
cfg := DanmakuRenderConfig{
@@ -190,90 +206,94 @@ func (s *DanmakuService) Fetch(ctx context.Context, mediaID, keyword, episodeID
configured := strings.TrimRight(strings.TrimSpace(res.Source), "/")
official := danmakuOfficialBase
// 手动指定弹幕库:跳过识别,直接拉取该库(自定义源失败回退官方)。
if target := strings.TrimSpace(episodeID); target != "" {
raw, st, err := s.fetchCommentWithFallback(ctx, configured, official, target)
if err != nil {
s.log.Warn("danmaku comment fetch failed", zap.String("media_id", mediaID), zap.String("episode_id", target), zap.Error(err))
return res, err
}
res.Raw, res.SourceType = raw, st
if id, parseErr := strconv.ParseInt(target, 10, 64); parseErr == nil {
res.EpisodeID = id
}
res.MatchMode = "manual"
return res, nil
}
term, media, err := s.searchTerms(ctx, mediaID)
// 手动指定弹幕库:跳过识别,直接拉取该库(自定义源失败回退官方)。
if target := strings.TrimSpace(episodeID); target != "" {
raw, st, err := s.fetchCommentWithFallback(ctx, configured, official, target)
if err != nil {
s.log.Warn("danmaku comment fetch failed", zap.String("media_id", mediaID), zap.String("episode_id", target), zap.Error(err))
return res, err
}
manualKeyword := strings.TrimSpace(keyword) != ""
if kw := strings.TrimSpace(keyword); kw != "" {
term.name = kw
res.Raw, res.SourceType = raw, st
if id, parseErr := strconv.ParseInt(target, 10, 64); parseErr == nil {
res.EpisodeID = id
}
if strings.TrimSpace(term.name) == "" {
res.MatchMode = "manual"
return res, nil
}
term, media, err := s.searchTerms(ctx, mediaID)
if err != nil {
return res, err
}
manualKeyword := strings.TrimSpace(keyword) != ""
if kw := strings.TrimSpace(keyword); kw != "" {
term.name = kw
}
if strings.TrimSpace(term.name) == "" {
return res, nil
}
target := ""
// 1) hash 识别:始终走官方 /api/v2/match(keyword 手动覆盖时跳过,直接走第 3 层)。
if target == "" && !manualKeyword && media != nil && (media.Path != "" || IsEmbyRemoteID(media.ID)) {
if hash, ok := s.mediaHash(ctx, media); ok {
fileSize := media.SizeBytes
if media.Path != "" && strings.EqualFold(filepath.Ext(media.Path), ".strm") {
fileSize = 0 // strm 行的 SizeBytes 是文本大小,不是视频大小
}
matchName := danmakuMatchFileName(media.Path)
if matchName == "" {
matchName = term.name
}
matches, err := s.matchOfficial(ctx, matchName, hash, fileSize, media.DurationSec)
if err != nil {
s.log.Warn("danmaku hash match failed", zap.String("media_id", mediaID), zap.Error(err))
} else if len(matches) > 0 {
target = fmt.Sprintf("%d", matches[0].EpisodeID)
res.AnimeTitle = matches[0].AnimeTitle
res.EpisodeTitle = matches[0].EpisodeTitle
res.EpisodeID = matches[0].EpisodeID
res.MatchMode = "hash"
}
}
}
// 2) 按播放的文件名 + 集数搜索(keyword 手动覆盖时跳过,直接走第 3 层)。
if target == "" && !manualKeyword && media != nil && media.Path != "" {
if fileName := danmakuMatchFileName(media.Path); fileName != "" && fileName != term.name {
if candidates, err := s.searchCandidatesWithFallback(ctx, configured, official, fileName, term.episode); err == nil &&
len(candidates) == 1 && len(candidates[0].Episodes) > 0 {
target = fmt.Sprintf("%d", candidates[0].Episodes[0].EpisodeID)
res.AnimeTitle = candidates[0].AnimeTitle
res.EpisodeTitle = candidates[0].Episodes[0].EpisodeTitle
res.EpisodeID = candidates[0].Episodes[0].EpisodeID
res.MatchMode = "filename"
}
}
}
// 3) 现有自动识别:标题层级(original_name → title → 文件名)+ 集数,
// 多结果返回候选列表交给播放器(歧义处理)。
if target == "" {
candidates, err := s.searchCandidatesWithFallback(ctx, configured, official, term.name, term.episode)
if err != nil {
s.log.Warn("danmaku search failed", zap.String("media_id", mediaID), zap.String("name", term.name), zap.String("episode", term.episode), zap.Error(err))
return res, err
}
if len(candidates) != 1 {
res.Candidates = candidates
return res, nil
}
target := ""
// 1) hash 识别:始终走官方 /api/v2/match(keyword 手动覆盖时跳过,直接走第 3 层)。
if target == "" && !manualKeyword && media != nil && media.Path != "" {
if hash, ok := s.mediaHash(ctx, media); ok {
fileSize := media.SizeBytes
if strings.EqualFold(filepath.Ext(media.Path), ".strm") {
fileSize = 0 // strm 行的 SizeBytes 是文本大小,不是视频大小
}
matches, err := s.matchOfficial(ctx, danmakuMatchFileName(media.Path), hash, fileSize, media.DurationSec)
if err != nil {
s.log.Warn("danmaku hash match failed", zap.String("media_id", mediaID), zap.Error(err))
} else if len(matches) > 0 {
target = fmt.Sprintf("%d", matches[0].EpisodeID)
res.AnimeTitle = matches[0].AnimeTitle
res.EpisodeTitle = matches[0].EpisodeTitle
res.EpisodeID = matches[0].EpisodeID
res.MatchMode = "hash"
}
}
}
// 2) 按播放的文件名 + 集数搜索(keyword 手动覆盖时跳过,直接走第 3 层)。
if target == "" && !manualKeyword && media != nil && media.Path != "" {
if fileName := danmakuMatchFileName(media.Path); fileName != "" && fileName != term.name {
if candidates, err := s.searchCandidatesWithFallback(ctx, configured, official, fileName, term.episode); err == nil &&
len(candidates) == 1 && len(candidates[0].Episodes) > 0 {
target = fmt.Sprintf("%d", candidates[0].Episodes[0].EpisodeID)
res.AnimeTitle = candidates[0].AnimeTitle
res.EpisodeTitle = candidates[0].Episodes[0].EpisodeTitle
res.EpisodeID = candidates[0].Episodes[0].EpisodeID
res.MatchMode = "filename"
}
}
}
// 3) 现有自动识别:标题层级(original_name → title → 文件名)+ 集数,
// 多结果返回候选列表交给播放器(歧义处理)。
if target == "" {
candidates, err := s.searchCandidatesWithFallback(ctx, configured, official, term.name, term.episode)
if err != nil {
s.log.Warn("danmaku search failed", zap.String("media_id", mediaID), zap.String("name", term.name), zap.String("episode", term.episode), zap.Error(err))
return res, err
}
if len(candidates) != 1 {
res.Candidates = candidates
return res, nil
}
if len(candidates[0].Episodes) == 0 {
return res, errors.New("no danmaku library found for this video")
}
target = fmt.Sprintf("%d", candidates[0].Episodes[0].EpisodeID)
res.AnimeTitle = candidates[0].AnimeTitle
res.EpisodeTitle = candidates[0].Episodes[0].EpisodeTitle
res.EpisodeID = candidates[0].Episodes[0].EpisodeID
res.MatchMode = "search"
if len(candidates[0].Episodes) == 0 {
return res, errors.New("no danmaku library found for this video")
}
target = fmt.Sprintf("%d", candidates[0].Episodes[0].EpisodeID)
res.AnimeTitle = candidates[0].AnimeTitle
res.EpisodeTitle = candidates[0].Episodes[0].EpisodeTitle
res.EpisodeID = candidates[0].Episodes[0].EpisodeID
res.MatchMode = "search"
}
raw, st, err := s.fetchCommentWithFallback(ctx, configured, official, target)
if err != nil {
@@ -311,6 +331,29 @@ type danmakuSearchTerms struct {
// (movies / unknown) is left empty so the search does not filter by episode.
func (s *DanmakuService) searchTerms(ctx context.Context, mediaID string) (danmakuSearchTerms, *model.Media, error) {
var term danmakuSearchTerms
if IsEmbyRemoteID(mediaID) {
if s == nil || s.remoteResolve == nil {
return term, nil, errors.New("remote emby resolver unavailable")
}
m, _, err := s.remoteResolve(ctx, mediaID)
if err != nil || m == nil {
if err != nil {
return term, nil, err
}
return term, nil, errors.New("media not found")
}
if name := strings.TrimSpace(m.OriginalName); name != "" {
term.name = name
} else if name := strings.TrimSpace(m.Title); name != "" {
term.name = name
} else {
term.name = danmakuMatchFileName(m.Path)
}
if m.EpisodeNum > 0 {
term.episode = strconv.Itoa(m.EpisodeNum)
}
return term, m, nil
}
if s == nil || s.repo == nil || s.repo.Media == nil {
return term, nil, errors.New("media repository unavailable")
}
@@ -484,7 +527,14 @@ func (s *DanmakuService) hashCachePut(stamp, hash string) {
// ("xxx.mkv.strm") — so a second strip removes a real video extension only
// (filepath.Ext would misread names like "xxx.第01话" as having an extension).
func danmakuMatchFileName(path string) string {
base := filepath.Base(path)
if path == "" {
return ""
}
clean := strings.ReplaceAll(path, "\\", "/")
if idx := strings.LastIndex(clean, "/"); idx >= 0 {
clean = clean[idx+1:]
}
base := filepath.Base(clean)
if ext := filepath.Ext(base); ext != "" {
base = strings.TrimSuffix(base, ext)
}
@@ -493,14 +543,20 @@ func danmakuMatchFileName(path string) string {
base = strings.TrimSuffix(base, filepath.Ext(base))
}
}
return base
return strings.TrimSpace(base)
}
// mediaHash returns the dandanplay match hash (MD5 of the first 16MB of the
// video). Local videos are hashed straight from disk; .strm indirections are
// resolved (local path / direct link) and only the 16MB prefix is downloaded.
// video). Local videos are hashed straight from disk; .strm indirections and
// remote Emby streams are range-fetched and only the 16MB prefix is downloaded.
func (s *DanmakuService) mediaHash(ctx context.Context, media *model.Media) (string, bool) {
if media == nil || media.Path == "" {
if media == nil {
return "", false
}
if IsEmbyRemoteID(media.ID) {
return s.hashEmbyRemote(ctx, media)
}
if media.Path == "" {
return "", false
}
if strings.EqualFold(filepath.Ext(media.Path), ".strm") {
@@ -517,6 +573,43 @@ func (s *DanmakuService) mediaHash(ctx context.Context, media *model.Media) (str
return s.hashLocalFile(media.Path)
}
// hashEmbyRemote computes the 16MB-prefix MD5 of a remote Emby stream via HTTP Range.
func (s *DanmakuService) hashEmbyRemote(ctx context.Context, media *model.Media) (string, bool) {
if media == nil || media.ID == "" {
return "", false
}
if h, ok := s.hashCacheGet("e|" + media.ID); ok {
return h, true
}
if s.remoteResolve == nil {
return "", false
}
_, streamURL, err := s.remoteResolve(ctx, media.ID)
if err != nil || strings.TrimSpace(streamURL) == "" {
if err != nil {
s.log.Warn("danmaku emby stream url resolve failed, hash layer skipped",
zap.String("media_id", media.ID), zap.Error(err))
}
return "", false
}
body, err := s.openRangeBody(ctx, streamURL, nil)
if err != nil || body == nil {
if err != nil {
s.log.Warn("danmaku emby range fetch failed, hash layer skipped",
zap.String("media_id", media.ID), zap.Error(err))
}
return "", false
}
defer body.Close()
h := md5.New()
if _, err := io.Copy(h, io.LimitReader(body, danmakuHashPrefixBytes)); err != nil {
return "", false
}
hash := hex.EncodeToString(h.Sum(nil))
s.hashCachePut("e|"+media.ID, hash)
return hash, true
}
// hashLocalFile computes the MD5 of the first 16MB of a local video, cached
// by path+size+mtime so repeated danmaku loads skip the disk read.
func (s *DanmakuService) hashLocalFile(path string) (string, bool) {
@@ -694,7 +787,7 @@ func (s *DanmakuService) matchOfficial(ctx context.Context, fileName, fileHash s
return nil, fmt.Errorf("danmaku match returned HTTP %d", resp.StatusCode)
}
var out struct {
Success bool `json:"success"`
Success bool `json:"success"`
Matches []danmakuMatch `json:"matches"`
}
if err := json.Unmarshal(raw, &out); err != nil {
+15 -1
View File
@@ -153,7 +153,14 @@ func (e *EmbyService) Items(ctx context.Context, p ItemsParams) (map[string]any,
if mount == nil || acct == nil {
return emptyItemsEnvelope(p.StartIndex), nil
}
return e.remote.RemoteItems(ctx, mount, acct, p)
out, err := e.remote.RemoteItems(ctx, mount, acct, p)
if err != nil {
return nil, err
}
if err := e.mergeRemoteUserData(ctx, p.UserID, out); err != nil {
return nil, err
}
return out, nil
}
// 全局搜索:无 ParentId 且带搜索词 → 聚合本地 + 全部远程。
if p.ParentID == "" && p.SearchTerm != "" {
@@ -161,6 +168,10 @@ func (e *EmbyService) Items(ctx context.Context, p ItemsParams) (map[string]any,
}
}
if containsEmbyFilter(p.Filters, "IsResumable") {
return e.resumableItems(ctx, p)
}
if len(p.IDs) > 0 {
items := make([]map[string]any, 0, len(p.IDs))
for _, id := range p.IDs {
@@ -274,6 +285,9 @@ func (e *EmbyService) aggregatedSearch(ctx context.Context, p ItemsParams) (map[
}
continue
}
if err := e.mergeRemoteUserData(ctx, p.UserID, remote); err != nil {
return nil, err
}
if raw, ok := remote["Items"].([]any); ok {
results = append(results, remoteResult{items: raw})
} else if rawMap, ok := remote["Items"].([]map[string]any); ok {
+85 -21
View File
@@ -11,6 +11,9 @@ import (
// Item 单条目详情。
func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[string]any, error) {
if e == nil {
return nil, nil
}
// 远程 Emby 条目:不查本地库,直接向远程转发(保持远程最新元数据)。
if e.remote != nil && IsEmbyRemoteID(mediaID) {
mountID, remoteID, _ := DecodeEmbyRemoteID(mediaID)
@@ -18,7 +21,14 @@ func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[str
if mount == nil || acct == nil {
return nil, nil
}
return e.remote.RemoteItem(ctx, mount, acct, remoteID)
out, err := e.remote.RemoteItem(ctx, mount, acct, remoteID)
if err != nil || out == nil {
return out, err
}
if err := e.mergeRemoteUserData(ctx, userID, out); err != nil {
return nil, err
}
return out, nil
}
if lib, err := e.repo.Library.FindByID(ctx, mediaID); err != nil {
return nil, err
@@ -91,7 +101,14 @@ func (e *EmbyService) LatestItems(ctx context.Context, userID, parentID string,
if mount == nil || acct == nil {
return nil, nil
}
return e.remote.RemoteLatest(ctx, mount, acct, remoteParent, limit)
out, err := e.remote.RemoteLatest(ctx, mount, acct, remoteParent, limit)
if err != nil {
return nil, err
}
if err := e.mergeRemoteUserData(ctx, userID, out); err != nil {
return nil, err
}
return out, nil
}
cacheKey := e.embyLatestCacheKey(userID, parentID, limit)
var cached embyLatestCacheValue
@@ -160,41 +177,88 @@ func (e *EmbyService) latestSeriesItemsForLibrary(ctx context.Context, userID, l
// ResumeItems 列出有未完成播放进度的媒体。
func (e *EmbyService) ResumeItems(ctx context.Context, userID string, limit int) (map[string]any, error) {
if limit <= 0 || limit > 100 {
limit = 20
return e.resumableItems(ctx, ItemsParams{UserID: userID, Limit: limit})
}
// resumableItems 返回未完成播放进度的媒体(包含本地媒体与挂载的远程媒体),支持分页。
func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[string]any, error) {
if p.Limit <= 0 || p.Limit > 100 {
p.Limit = 50
}
if p.StartIndex < 0 {
p.StartIndex = 0
}
if strings.TrimSpace(p.UserID) == "" {
return map[string]any{"Items": []any{}, "TotalRecordCount": int64(0), "StartIndex": p.StartIndex}, nil
}
var hist []model.PlaybackHistory
if err := e.repo.DB.WithContext(ctx).
Where("user_id = ? AND completed = ? AND position_ms > 0", userID, false).
Order("watched_at desc").Limit(limit).Find(&hist).Error; err != nil {
Where("user_id = ? AND completed = ? AND position_ms > 0", p.UserID, false).
Order("watched_at desc").Find(&hist).Error; err != nil {
return nil, err
}
if len(hist) == 0 {
return map[string]any{"Items": []any{}, "TotalRecordCount": 0}, nil
return map[string]any{"Items": []any{}, "TotalRecordCount": int64(0), "StartIndex": p.StartIndex}, nil
}
ids := make([]string, 0, len(hist))
posByID := map[string]int64{}
localIDs := make([]string, 0, len(hist))
for _, h := range hist {
ids = append(ids, h.MediaID)
posByID[h.MediaID] = h.PositionMs
}
var medias []model.Media
q := e.repo.DB.WithContext(ctx).Where("id IN ?", ids)
q = e.applyUserMediaVisibility(ctx, q, userID)
if err := q.Find(&medias).Error; err != nil {
return nil, err
if !IsEmbyRemoteID(h.MediaID) {
localIDs = append(localIDs, h.MediaID)
}
}
byID := map[string]*model.Media{}
for i := range medias {
byID[medias[i].ID] = &medias[i]
if len(localIDs) > 0 {
var medias []model.Media
q := e.repo.DB.WithContext(ctx).Where("id IN ?", localIDs)
q = e.applyUserMediaVisibility(ctx, q, p.UserID)
if err := q.Find(&medias).Error; err != nil {
return nil, err
}
for i := range medias {
byID[medias[i].ID] = &medias[i]
}
}
items := make([]map[string]any, 0, len(hist))
for _, h := range hist {
if m, ok := byID[h.MediaID]; ok {
items = append(items, e.itemPayload(ctx, m, false, posByID[h.MediaID]))
if p.ParentID != "" && m.LibraryID != p.ParentID && m.SeriesID != p.ParentID {
continue
}
items = append(items, e.itemPayload(ctx, m, false, h.PositionMs))
continue
}
if e.remote == nil || !IsEmbyRemoteID(h.MediaID) {
continue
}
mountID, remoteID, _ := DecodeEmbyRemoteID(h.MediaID)
mount, acct, err := e.remote.ResolveMount(ctx, mountID)
if err != nil || mount == nil || acct == nil {
continue
}
item, err := e.remote.RemoteItem(ctx, mount, acct, remoteID)
if err != nil || item == nil {
continue
}
if p.ParentID != "" {
parentID, _ := item["ParentId"].(string)
seriesID, _ := item["SeriesId"].(string)
if parentID != p.ParentID && seriesID != p.ParentID && mountID != p.ParentID {
continue
}
}
item["UserData"] = mergedRemoteUserData(item["UserData"], &h)
items = append(items, item)
}
return map[string]any{"Items": items, "TotalRecordCount": len(items)}, nil
total := int64(len(items))
if p.StartIndex >= len(items) {
return map[string]any{"Items": []map[string]any{}, "TotalRecordCount": total, "StartIndex": p.StartIndex}, nil
}
end := minInt(p.StartIndex+p.Limit, len(items))
return map[string]any{"Items": items[p.StartIndex:end], "TotalRecordCount": total, "StartIndex": p.StartIndex}, nil
}
func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool, posMs int64) map[string]any {
+3
View File
@@ -31,6 +31,9 @@ func (e *EmbyService) PlaybackInfo(ctx context.Context, mediaID, userID string)
if out == nil {
return nil, ErrEmbyRemoteNotFound
}
if err := e.mergeRemoteUserData(ctx, userID, out); err != nil {
return nil, err
}
out["PlaySessionId"] = fmt.Sprintf("remote-%s-%d", mountID, time.Now().Unix())
return out, nil
}
+67 -8
View File
@@ -15,6 +15,8 @@ package service
import (
"context"
"crypto/sha1"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
@@ -69,6 +71,7 @@ type EmbyRemoteService struct {
repo *repository.Container
crypto *CryptoService
http *http.Client
cache *RuntimeCacheService
}
// NewEmbyRemoteService 构造远程 Emby 聚合服务。
@@ -85,6 +88,32 @@ func NewEmbyRemoteService(cfg *config.Config, log *zap.Logger, repo *repository.
}
}
func (r *EmbyRemoteService) SetRuntimeCache(cache *RuntimeCacheService) *EmbyRemoteService {
if r != nil {
r.cache = cache
}
return r
}
func (r *EmbyRemoteService) remoteMediaCacheTTL() time.Duration {
seconds := 15
if r != nil && r.cfg != nil && r.cfg.Cache.MediaTTLSeconds > 0 {
seconds = r.cfg.Cache.MediaTTLSeconds
}
return time.Duration(seconds) * time.Second
}
func (r *EmbyRemoteService) remoteCacheKey(parts ...string) string {
sum := sha1.Sum([]byte(strings.Join(parts, "|")))
return "media:embyremote:" + hex.EncodeToString(sum[:])
}
func (r *EmbyRemoteService) invalidateRemoteMediaCache(ctx context.Context) {
if r != nil && r.cache != nil {
r.cache.DeletePrefix(ctx, "media:embyremote:")
}
}
// ListAccounts 返回全部启用的远程 Emby 挂载账号。
func (r *EmbyRemoteService) ListAccounts(ctx context.Context) ([]model.StrmAccount, error) {
accounts, err := r.repo.StrmAccount.List(ctx)
@@ -143,6 +172,7 @@ func (r *EmbyRemoteService) CreateMount(ctx context.Context, m *model.EmbyMount)
if err := r.repo.EmbyMount.Create(ctx, m); err != nil {
return nil, err
}
r.invalidateRemoteMediaCache(ctx)
return m, nil
}
@@ -172,6 +202,7 @@ func (r *EmbyRemoteService) CreateMounts(ctx context.Context, mounts []*model.Em
if err := r.repo.EmbyMount.CreateInBatches(ctx, fresh, 50); err != nil {
return 0, err
}
r.invalidateRemoteMediaCache(ctx)
return len(fresh), nil
}
@@ -187,12 +218,29 @@ func (r *EmbyRemoteService) UpdateMount(ctx context.Context, id string, m *model
if err := r.repo.EmbyMount.Update(ctx, existing); err != nil {
return nil, err
}
r.invalidateRemoteMediaCache(ctx)
return existing, nil
}
// DeleteMount 删除挂载。
func (r *EmbyRemoteService) DeleteMount(ctx context.Context, id string) error {
return r.repo.EmbyMount.Delete(ctx, id)
err := r.repo.EmbyMount.Delete(ctx, id)
if err == nil {
r.invalidateRemoteMediaCache(ctx)
}
return err
}
// ReorderMounts 批量重排挂载媒体库顺序。
func (r *EmbyRemoteService) ReorderMounts(ctx context.Context, ids []string) error {
if len(ids) == 0 {
return nil
}
if err := r.repo.EmbyMount.SetSortOrder(ctx, ids); err != nil {
return err
}
r.invalidateRemoteMediaCache(ctx)
return nil
}
// FullMountAccount 把账号的全部远程媒体库(View)挂载进来(幂等,已存在跳过)。
@@ -407,12 +455,12 @@ func (r *EmbyRemoteService) doGet(ctx context.Context, acct *model.StrmAccount,
// token 失效:清空后重认证重试一次。
cfg.Token = ""
if acct != nil {
raw := map[string]string{}
_ = json.Unmarshal([]byte(acct.Config), &raw)
delete(raw, "api_key")
enc, _ := json.Marshal(raw)
acct.Config = string(enc)
_ = r.repo.StrmAccount.Update(ctx, acct)
raw := map[string]string{}
_ = json.Unmarshal([]byte(acct.Config), &raw)
delete(raw, "api_key")
enc, _ := json.Marshal(raw)
acct.Config = string(enc)
_ = r.repo.StrmAccount.Update(ctx, acct)
}
continue
}
@@ -457,6 +505,11 @@ func (r *EmbyRemoteService) RemoteViews(ctx context.Context, acct *model.StrmAcc
if err != nil {
return nil, err
}
cacheKey := r.remoteCacheKey("views", acct.ID, r.remoteUserID(cfg))
var cached []map[string]any
if r.cache != nil && r.cache.GetJSON(ctx, cacheKey, &cached) {
return cached, nil
}
q := url.Values{"api_key": {cfg.Token}}
var body struct {
Items []map[string]any `json:"Items"`
@@ -464,6 +517,12 @@ func (r *EmbyRemoteService) RemoteViews(ctx context.Context, acct *model.StrmAcc
if err := r.doGet(ctx, acct, cfg, "/Users/"+url.PathEscape(r.remoteUserID(cfg))+"/Views", q, &body); err != nil {
return nil, err
}
if body.Items == nil {
body.Items = []map[string]any{}
}
if r.cache != nil {
r.cache.SetJSON(ctx, cacheKey, body.Items, r.remoteMediaCacheTTL())
}
return body.Items, nil
}
@@ -859,4 +918,4 @@ func (r *EmbyRemoteService) doMutate(ctx context.Context, acct *model.StrmAccoun
return fmt.Errorf("远程 Emby 状态同步失败(%d): %s", resp.StatusCode, strings.TrimSpace(string(data)))
}
return nil
}
}
+1 -1
View File
@@ -106,4 +106,4 @@ func rewriteEmbyRemoteIDsMap(m map[string]any, accountID string) {
if items, ok := m["Items"]; ok {
RewriteEmbyRemoteIDs(items, accountID)
}
}
}
+8 -8
View File
@@ -42,11 +42,11 @@ func TestDecodeEmbyRemoteIDRejectsLocalUUIDs(t *testing.T) {
func TestRewriteEmbyRemoteIDs(t *testing.T) {
payload := map[string]any{
"Id": "item-1",
"ParentId": "folder-1",
"SeriesId": "series-1",
"SeasonId": "season-1",
"PrimaryImageItemId": "item-1",
"Id": "item-1",
"ParentId": "folder-1",
"SeriesId": "series-1",
"SeasonId": "season-1",
"PrimaryImageItemId": "item-1",
"DisplayPreferencesId": "folder-1",
"ImageTags": map[string]any{
"Primary": "item-1",
@@ -58,8 +58,8 @@ func TestRewriteEmbyRemoteIDs(t *testing.T) {
// MediaSource 的 Id 保持原样(客户端仅作为 MediaSourceId 查询参数)。
"MediaSources": []any{
map[string]any{
"Id": "ms-9",
"DirectStreamUrl": "/Videos/item-1/stream",
"Id": "ms-9",
"DirectStreamUrl": "/Videos/item-1/stream",
"MediaStreams": []any{
map[string]any{"Type": "Subtitle", "DeliveryUrl": "/Videos/item-1/Subtitles/2/Stream.srt"},
},
@@ -99,4 +99,4 @@ func TestRewriteEmbyRemoteIDs(t *testing.T) {
if ms["DirectStreamUrl"] != "/Videos/item-1/stream" {
t.Fatalf("DirectStreamUrl must stay raw, got %v", ms["DirectStreamUrl"])
}
}
}
+46 -1
View File
@@ -2,6 +2,8 @@ package service
import (
"testing"
"github.com/ShukeBta/MMTL/internal/model"
)
// rewriteSubtitleDeliveryURLs 只应改动字幕轨道的 DeliveryUrl,其余媒体流不动。
@@ -51,4 +53,47 @@ func TestRewriteSubtitleDeliveryURLsFallsBackIndexOne(t *testing.T) {
if got := streams[0].(map[string]any)["DeliveryUrl"]; got != want {
t.Fatalf("subtitle DeliveryUrl = %v, want %v", got, want)
}
}
}
func TestMapRemoteItemToMediaExtractsCodecsAndContainer(t *testing.T) {
r := &EmbyRemoteService{}
item := map[string]any{
"Id": "item-100",
"Name": "Test Movie",
"Container": "mkv",
"MediaStreams": []any{
map[string]any{
"Type": "Video",
"Codec": "h264",
"Width": 1920,
"Height": 1080,
},
map[string]any{
"Type": "Audio",
"Codec": "aac",
},
},
"MediaSources": []any{
map[string]any{
"Container": "mkv",
"Size": int64(104857600),
},
},
}
media := r.MapRemoteItemToMedia(t.Context(), nil, &model.StrmAccount{Base: model.Base{ID: "acct-1"}}, &EmbyRemoteConfig{}, item)
if media.Container != "mkv" {
t.Fatalf("media.Container = %v, want mkv", media.Container)
}
if media.VideoCodec != "h264" {
t.Fatalf("media.VideoCodec = %v, want h264", media.VideoCodec)
}
if media.AudioCodec != "aac" {
t.Fatalf("media.AudioCodec = %v, want aac", media.AudioCodec)
}
if media.Width != 1920 || media.Height != 1080 {
t.Fatalf("resolution = %dx%d, want 1920x1080", media.Width, media.Height)
}
if media.SizeBytes != 104857600 {
t.Fatalf("size = %d, want 104857600", media.SizeBytes)
}
}
+181 -33
View File
@@ -13,6 +13,7 @@ import (
"sort"
"strconv"
"strings"
"time"
"go.uber.org/zap"
@@ -36,23 +37,28 @@ func (r *EmbyRemoteService) RemoteLibraries(ctx context.Context) ([]RemoteLibrar
if err != nil || len(mounts) == 0 {
return nil, err
}
// 按账号分组,每账号拉一次 Views 做匹配。
byAccount := map[string][]*model.EmbyMount{}
type accountData struct {
acct *model.StrmAccount
cfg *EmbyRemoteConfig
viewByName map[string]map[string]any
}
acctData := map[string]*accountData{}
for i := range mounts {
m := mounts[i]
m := &mounts[i]
if !m.Enabled {
continue
}
byAccount[m.AccountID] = append(byAccount[m.AccountID], &mounts[i])
}
out := make([]RemoteLibraryView, 0, len(mounts))
for accountID, accountMounts := range byAccount {
acct := r.AccountByID(ctx, accountID)
if _, ok := acctData[m.AccountID]; ok {
continue
}
acct := r.AccountByID(ctx, m.AccountID)
if acct == nil {
acctData[m.AccountID] = nil
continue
}
cfg, cfgErr := r.configOf(acct)
if cfgErr != nil {
acctData[m.AccountID] = nil
continue
}
views, viewErr := r.RemoteViews(ctx, acct)
@@ -61,31 +67,47 @@ func (r *EmbyRemoteService) RemoteLibraries(ctx context.Context) ([]RemoteLibrar
r.log.Warn("web remote emby views failed",
zap.String("account", acct.Name), zap.Error(viewErr))
}
acctData[m.AccountID] = nil
continue
}
viewByName := map[string]map[string]any{}
for _, v := range views {
viewByName[remoteItemString(v, "Id")] = v
}
for _, mount := range accountMounts {
v, ok := viewByName[mount.RemoteViewID]
if !ok {
continue // 远程已删除该媒体库
}
lib := r.mapRemoteMountToLibrary(mount, acct, cfg, v)
if lib == nil {
continue
}
out = append(out, RemoteLibraryView{
Library: *lib,
MountID: mount.ID,
AccountID: acct.ID,
RemoteID: mount.RemoteViewID,
CollectionType: mount.CollectionType,
AccountName: acct.Name,
})
acctData[m.AccountID] = &accountData{
acct: acct,
cfg: cfg,
viewByName: viewByName,
}
}
out := make([]RemoteLibraryView, 0, len(mounts))
for i := range mounts {
m := &mounts[i]
if !m.Enabled {
continue
}
data := acctData[m.AccountID]
if data == nil || data.viewByName == nil {
continue
}
v, ok := data.viewByName[m.RemoteViewID]
if !ok {
continue // 远程已删除该媒体库
}
lib := r.mapRemoteMountToLibrary(m, data.acct, data.cfg, v)
if lib == nil {
continue
}
out = append(out, RemoteLibraryView{
Library: *lib,
MountID: m.ID,
AccountID: data.acct.ID,
RemoteID: m.RemoteViewID,
CollectionType: m.CollectionType,
AccountName: data.acct.Name,
})
}
return out, nil
}
@@ -130,7 +152,7 @@ func (r *EmbyRemoteService) mapRemoteMountToLibrary(mount *model.EmbyMount, acct
Name: name,
Type: libType,
Enabled: true,
SortOrder: 1000, // 远程库排在本地库之后
SortOrder: 1000 + mount.SortOrder, // 远程库排在本地库之后,且保持挂载库排序
}
// 远程媒体库封面只有真实存在图片标签才下发。
if remoteItemHasImageTag(item, "Primary") {
@@ -156,17 +178,25 @@ func (r *EmbyRemoteService) MapRemoteItemToMedia(ctx context.Context, mount *mod
if _, rid, ok := DecodeEmbyRemoteID(seriesID); ok {
seriesID = rid
}
rating := remoteItemFloat(item, "CommunityRating")
if rating == 0 {
rating = remoteItemFloat(item, "CriticRating")
}
media := model.Media{
Base: model.Base{ID: EncodeEmbyRemoteID(encodeScope, remoteID)},
Title: remoteItemString(item, "Name"),
OriginalName: remoteItemString(item, "OriginalTitle"),
Overview: remoteItemString(item, "Overview"),
Year: remoteItemInt(item, "ProductionYear"),
Rating: float32(remoteItemFloat(item, "CommunityRating")),
Rating: float32(rating),
Path: remoteItemString(item, "Path"),
Genres: remoteItemGenres(item),
ScrapeStatus: "done",
}
if date, ok := parseEmbyRemoteDate(remoteItemString(item, "DateCreated")); ok {
media.CreatedAt = date
media.UpdatedAt = date
}
// 只有远程明确存在图片标签才下发图片 URL。
if remoteItemHasImageTag(item, "Primary") {
media.PosterURL = r.remoteItemImageURL(cfg, remoteID, "Primary")
@@ -177,8 +207,16 @@ func (r *EmbyRemoteService) MapRemoteItemToMedia(ctx context.Context, mount *mod
if ticks := remoteItemInt64(item, "RunTimeTicks"); ticks > 0 {
media.DurationSec = int(ticks / 10_000_000)
}
if date, ok := embyPremiereDate(remoteItemString(item, "PremiereDate")); ok {
if date, ok := parseEmbyRemoteDate(remoteItemString(item, "PremiereDate")); ok {
media.ReleaseDate = date.Format("2006-01-02")
if media.Year == 0 {
media.Year = date.Year()
}
} else if date, ok := embyPremiereDate(remoteItemString(item, "PremiereDate")); ok {
media.ReleaseDate = date.Format("2006-01-02")
if media.Year == 0 {
media.Year = date.Year()
}
}
if providerIDs, ok := item["ProviderIds"].(map[string]any); ok {
if v := anyString(providerIDs["Tmdb"]); v != "" {
@@ -191,6 +229,59 @@ func (r *EmbyRemoteService) MapRemoteItemToMedia(ctx context.Context, mount *mod
media.DoubanID = v
}
}
media.Container = remoteItemString(item, "Container")
media.Width = remoteItemInt(item, "Width")
media.Height = remoteItemInt(item, "Height")
extractStreamInfo := func(streams []any) {
for _, s := range streams {
sm, ok := s.(map[string]any)
if !ok {
continue
}
typ := remoteItemString(sm, "Type")
if strings.EqualFold(typ, "Video") {
if media.VideoCodec == "" {
media.VideoCodec = remoteItemString(sm, "Codec")
}
if media.Width == 0 {
media.Width = remoteItemInt(sm, "Width")
}
if media.Height == 0 {
media.Height = remoteItemInt(sm, "Height")
}
} else if strings.EqualFold(typ, "Audio") {
if media.AudioCodec == "" {
media.AudioCodec = remoteItemString(sm, "Codec")
}
}
}
}
if streams, ok := item["MediaStreams"].([]any); ok {
extractStreamInfo(streams)
} else if streams, ok := item["MediaStreams"].([]map[string]any); ok {
anyStreams := make([]any, len(streams))
for i, v := range streams {
anyStreams[i] = v
}
extractStreamInfo(anyStreams)
}
if sources, ok := item["MediaSources"].([]any); ok && len(sources) > 0 {
if sourceMap, ok := sources[0].(map[string]any); ok {
if media.Container == "" {
media.Container = remoteItemString(sourceMap, "Container")
}
if media.SizeBytes == 0 {
media.SizeBytes = remoteItemInt64(sourceMap, "Size")
}
if streams, ok := sourceMap["MediaStreams"].([]any); ok && (media.VideoCodec == "" || media.AudioCodec == "") {
extractStreamInfo(streams)
}
}
}
switch remoteItemString(item, "Type") {
case "Episode":
media.SeasonNum = remoteItemInt(item, "ParentIndexNumber")
@@ -221,13 +312,21 @@ func (r *EmbyRemoteService) RemoteLibraryMedia(ctx context.Context, mount *model
if itemTypes == "" {
itemTypes = "Movie,Series" // 未知类型时两者都取(前端自行按 episode-like 分组)
}
cacheKey := r.remoteCacheKey("library-media", acct.ID, mount.ID, remoteViewID, itemTypes, strconv.Itoa(offset), strconv.Itoa(limit))
var cached struct {
Items []model.Media `json:"items"`
TotalRecordCount int64 `json:"total_record_count"`
}
if r.cache != nil && r.cache.GetJSON(ctx, cacheKey, &cached) {
return cached.Items, cached.TotalRecordCount, nil
}
q := url.Values{}
q.Set("ParentId", remoteViewID)
q.Set("IncludeItemTypes", itemTypes)
q.Set("Recursive", "false")
q.Set("StartIndex", strconv.Itoa(offset))
q.Set("Limit", strconv.Itoa(limit))
q.Set("Fields", "Overview,Genres,ProviderIds,Path,SeriesPrimaryImage")
q.Set("Fields", "Overview,Genres,ProviderIds,Path,SeriesPrimaryImage,MediaStreams,MediaSources,DateCreated,PremiereDate,ProductionYear,CommunityRating,CriticRating")
var body struct {
Items []map[string]any `json:"Items"`
TotalRecordCount int64 `json:"TotalRecordCount"`
@@ -240,6 +339,12 @@ func (r *EmbyRemoteService) RemoteLibraryMedia(ctx context.Context, mount *model
RewriteEmbyRemoteIDs(it, mount.ID) // 嵌套/关联 ID 一并伪装
items = append(items, r.MapRemoteItemToMedia(ctx, mount, acct, cfg, it))
}
if r.cache != nil {
r.cache.SetJSON(ctx, cacheKey, struct {
Items []model.Media `json:"items"`
TotalRecordCount int64 `json:"total_record_count"`
}{Items: items, TotalRecordCount: body.TotalRecordCount}, r.remoteMediaCacheTTL())
}
return items, body.TotalRecordCount, nil
}
@@ -250,7 +355,7 @@ func (r *EmbyRemoteService) RemoteMediaDetail(ctx context.Context, mount *model.
return nil, err
}
path := "/Users/" + url.PathEscape(r.remoteUserID(cfg)) + "/Items/" + url.PathEscape(remoteID)
path += "?Fields=Overview,Genres,ProviderIds,People,Studios,Path"
path += "?Fields=Overview,Genres,ProviderIds,People,Studios,Path,MediaStreams,MediaSources,DateCreated,PremiereDate,ProductionYear,CommunityRating,CriticRating"
var out map[string]any
if err := r.doGet(ctx, acct, cfg, path, nil, &out); err != nil {
return nil, err
@@ -311,7 +416,7 @@ func (r *EmbyRemoteService) remoteEpisodesOf(ctx context.Context, mount *model.E
q.Set("Recursive", "true")
q.Set("StartIndex", "0")
q.Set("Limit", "500")
q.Set("Fields", "Overview,Genres,ProviderIds,Path,SeriesPrimaryImage")
q.Set("Fields", "Overview,Genres,ProviderIds,Path,SeriesPrimaryImage,MediaStreams,MediaSources,DateCreated,PremiereDate,ProductionYear,CommunityRating,CriticRating")
var body struct {
Items []map[string]any `json:"Items"`
TotalRecordCount int64 `json:"TotalRecordCount"`
@@ -334,13 +439,18 @@ func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, mount *model.
if err != nil {
return nil, err
}
cacheKey := r.remoteCacheKey("series-cards", acct.ID, mount.ID, remoteViewID)
var cached []SeriesCard
if r.cache != nil && r.cache.GetJSON(ctx, cacheKey, &cached) {
return cached, nil
}
q := url.Values{}
q.Set("ParentId", remoteViewID)
q.Set("IncludeItemTypes", "Series")
q.Set("Recursive", "false")
q.Set("StartIndex", "0")
q.Set("Limit", "1000")
q.Set("Fields", "Overview,Genres,ProviderIds,Path,RecursiveItemCount,SeriesPrimaryImage")
q.Set("Fields", "Overview,Genres,ProviderIds,Path,RecursiveItemCount,SeriesPrimaryImage,DateCreated,PremiereDate,ProductionYear,CommunityRating,CriticRating")
var body struct {
Items []map[string]any `json:"Items"`
}
@@ -361,6 +471,9 @@ func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, mount *model.
}
cards = append(cards, SeriesCard{Key: m.ID, Rep: m, LinkMedia: m, Count: count})
}
if r.cache != nil {
r.cache.SetJSON(ctx, cacheKey, cards, r.remoteMediaCacheTTL())
}
return cards, nil
}
@@ -370,6 +483,11 @@ func (r *EmbyRemoteService) RemoteLatestCards(ctx context.Context, mount *model.
if err != nil {
return nil, err
}
cacheKey := r.remoteCacheKey("latest-cards", acct.ID, mount.ID, remoteViewID, strconv.Itoa(limit))
var cached []SeriesCard
if r.cache != nil && r.cache.GetJSON(ctx, cacheKey, &cached) {
return cached, nil
}
items, err := r.RemoteLatest(ctx, mount, acct, remoteViewID, limit)
if err != nil {
return nil, err
@@ -379,6 +497,9 @@ func (r *EmbyRemoteService) RemoteLatestCards(ctx context.Context, mount *model.
m := r.MapRemoteItemToMedia(ctx, mount, acct, cfg, it)
cards = append(cards, SeriesCard{Key: m.ID, Rep: m, LinkMedia: m, Count: 0})
}
if r.cache != nil {
r.cache.SetJSON(ctx, cacheKey, cards, r.remoteMediaCacheTTL())
}
return cards, nil
}
@@ -449,6 +570,8 @@ func remoteItemInt(item map[string]any, key string) int {
return int(v)
case int:
return v
case int64:
return int(v)
case string:
n, _ := strconv.Atoi(v)
return n
@@ -463,6 +586,8 @@ func remoteItemInt64(item map[string]any, key string) int64 {
switch v := item[key].(type) {
case float64:
return int64(v)
case int64:
return v
case int:
return int64(v)
case string:
@@ -572,6 +697,7 @@ func remoteItemTypeOf(m *model.Media) string {
}
return "Movie"
}
// ─── 供 handler 层使用的远程 View 条目取值(导出薄封装) ──────────────────────
// RemoteItemIDString 提取远程 View 条目的 Id。
@@ -581,7 +707,29 @@ func RemoteItemIDString(item map[string]any) string { return remoteItemString(it
func RemoteItemNameString(item map[string]any) string { return remoteItemString(item, "Name") }
// RemoteItemCollectionType 提取远程 View 条目的 CollectionType。
func RemoteItemCollectionType(item map[string]any) string { return remoteItemString(item, "CollectionType") }
func RemoteItemCollectionType(item map[string]any) string {
return remoteItemString(item, "CollectionType")
}
// RemoteItemChildCount 提取远程 View 条目的 ChildCount。
func RemoteItemChildCount(item map[string]any) int { return remoteItemInt(item, "ChildCount") }
func parseEmbyRemoteDate(s string) (time.Time, bool) {
s = strings.TrimSpace(s)
if s == "" {
return time.Time{}, false
}
for _, layout := range []string{
time.RFC3339Nano,
time.RFC3339,
"2006-01-02T15:04:05.9999999Z",
"2006-01-02T15:04:05.9999999",
"2006-01-02T15:04:05",
"2006-01-02",
} {
if t, err := time.Parse(layout, s); err == nil {
return t, true
}
}
return time.Time{}, false
}
+67
View File
@@ -0,0 +1,67 @@
package service
import (
"context"
"testing"
"time"
"github.com/ShukeBta/MMTL/internal/model"
)
func TestMapRemoteItemToMediaSortingFields(t *testing.T) {
svc := &EmbyRemoteService{}
mount := &model.EmbyMount{Base: model.Base{ID: "mount-1"}}
acct := &model.StrmAccount{Base: model.Base{ID: "acct-1"}}
cfg := &EmbyRemoteConfig{BaseURL: "http://localhost:8096"}
item := map[string]any{
"Id": "item-1",
"Name": "测试电影",
"OriginalTitle": "Test Movie",
"ProductionYear": 2023,
"CommunityRating": 8.5,
"PremiereDate": "2023-05-12T00:00:00.0000000Z",
"DateCreated": "2024-01-15T08:30:00.0000000Z",
}
media := svc.MapRemoteItemToMedia(context.Background(), mount, acct, cfg, item)
if media.ReleaseDate != "2023-05-12" {
t.Fatalf("ReleaseDate = %q, want %q", media.ReleaseDate, "2023-05-12")
}
if media.Year != 2023 {
t.Fatalf("Year = %d, want 2023", media.Year)
}
if media.Rating != 8.5 {
t.Fatalf("Rating = %f, want 8.5", media.Rating)
}
expectedCreated, _ := time.Parse(time.RFC3339, "2024-01-15T08:30:00Z")
if !media.CreatedAt.Equal(expectedCreated) {
t.Fatalf("CreatedAt = %v, want %v", media.CreatedAt, expectedCreated)
}
if !media.UpdatedAt.Equal(expectedCreated) {
t.Fatalf("UpdatedAt = %v, want %v", media.UpdatedAt, expectedCreated)
}
}
func TestMapRemoteItemToMediaCriticRatingFallback(t *testing.T) {
svc := &EmbyRemoteService{}
mount := &model.EmbyMount{Base: model.Base{ID: "mount-1"}}
acct := &model.StrmAccount{Base: model.Base{ID: "acct-1"}}
cfg := &EmbyRemoteConfig{BaseURL: "http://localhost:8096"}
item := map[string]any{
"Id": "item-2",
"Name": "评分测试",
"CriticRating": 9.2,
"PremiereDate": "2022-10-01",
}
media := svc.MapRemoteItemToMedia(context.Background(), mount, acct, cfg, item)
if media.Rating != 9.2 {
t.Fatalf("Rating = %f, want 9.2 from CriticRating", media.Rating)
}
if media.Year != 2022 {
t.Fatalf("Year = %d, want 2022 from PremiereDate", media.Year)
}
}
+137 -3
View File
@@ -49,9 +49,13 @@ func (e *EmbyService) MarkPlayed(ctx context.Context, userID, mediaID string, pl
return nil
}
if !played {
return e.repo.DB.WithContext(ctx).
err := e.repo.DB.WithContext(ctx).
Where("user_id = ? AND media_id = ?", userID, mediaID).
Delete(&model.PlaybackHistory{}).Error
if err == nil {
e.invalidateEmbyItemsCache(ctx)
}
return err
}
m, err := e.repo.Media.FindByID(ctx, mediaID)
if err != nil || m == nil {
@@ -61,7 +65,7 @@ func (e *EmbyService) MarkPlayed(ctx context.Context, userID, mediaID string, pl
if dur <= 0 {
dur = 1
}
return e.repo.History.Upsert(ctx, &model.PlaybackHistory{
err = e.repo.History.Upsert(ctx, &model.PlaybackHistory{
UserID: userID,
MediaID: mediaID,
PositionMs: dur,
@@ -69,6 +73,10 @@ func (e *EmbyService) MarkPlayed(ctx context.Context, userID, mediaID string, pl
WatchedAt: time.Now(),
Completed: true,
})
if err == nil {
e.invalidateEmbyItemsCache(ctx)
}
return err
}
// RecordProgress 记录播放进度(来自 Emby 客户端的 /Sessions/Playing/Progress)。
@@ -79,10 +87,27 @@ func (e *EmbyService) RecordProgress(ctx context.Context, userID, mediaID string
// runtimeTicks 缺失时回退到 media.DurationSec
if m, _ := e.repo.Media.FindByID(ctx, mediaID); m != nil {
dur = int64(m.DurationSec) * 1000
} else if IsEmbyRemoteID(mediaID) {
// 远程挂载条目:尝试从既有历史记录或远程详情补齐时长
var oldHist model.PlaybackHistory
if err := e.repo.DB.WithContext(ctx).Where("user_id = ? AND media_id = ?", userID, mediaID).First(&oldHist).Error; err == nil && oldHist.DurationMs > 0 {
dur = oldHist.DurationMs
} else if e.remote != nil {
mountID, remoteID, _ := DecodeEmbyRemoteID(mediaID)
if mount, acct, _ := e.remote.ResolveMount(ctx, mountID); mount != nil && acct != nil {
if item, _ := e.remote.RemoteItem(ctx, mount, acct, remoteID); item != nil {
if ticks, ok := item["RunTimeTicks"].(float64); ok && ticks > 0 {
dur = int64(ticks) / 10_000
} else if ticks, ok := item["RunTimeTicks"].(int64); ok && ticks > 0 {
dur = ticks / 10_000
}
}
}
}
}
}
completed := dur > 0 && pos >= dur*9/10
return e.repo.History.Upsert(ctx, &model.PlaybackHistory{
err := e.repo.History.Upsert(ctx, &model.PlaybackHistory{
UserID: userID,
MediaID: mediaID,
PositionMs: pos,
@@ -90,6 +115,115 @@ func (e *EmbyService) RecordProgress(ctx context.Context, userID, mediaID string
WatchedAt: time.Now(),
Completed: completed,
})
if err == nil {
e.invalidateEmbyItemsCache(ctx)
}
return err
}
// mergeRemoteUserData applies the current MMTL user's locally recorded playback
// state to remote Emby payloads. Remote metadata remains authoritative unless the
// user has played the item through MMTL.
func (e *EmbyService) mergeRemoteUserData(ctx context.Context, userID string, payload any) error {
if strings.TrimSpace(userID) == "" || payload == nil {
return nil
}
items := remoteItemMaps(payload)
ids := make([]string, 0, len(items))
seen := make(map[string]struct{}, len(items))
for _, item := range items {
id, _ := item["Id"].(string)
if !IsEmbyRemoteID(id) {
continue
}
if _, ok := seen[id]; !ok {
ids = append(ids, id)
seen[id] = struct{}{}
}
}
if len(ids) == 0 {
return nil
}
var histories []model.PlaybackHistory
if err := e.repo.DB.WithContext(ctx).Where("user_id = ? AND media_id IN ?", userID, ids).Find(&histories).Error; err != nil {
return err
}
byMediaID := make(map[string]*model.PlaybackHistory, len(histories))
for i := range histories {
byMediaID[histories[i].MediaID] = &histories[i]
}
for _, item := range items {
id, _ := item["Id"].(string)
if h := byMediaID[id]; h != nil {
item["UserData"] = mergedRemoteUserData(item["UserData"], h)
}
}
return nil
}
func remoteItemMaps(payload any) []map[string]any {
items := make([]map[string]any, 0)
var visit func(any)
visit = func(value any) {
switch typed := value.(type) {
case map[string]any:
if _, ok := typed["Id"].(string); ok {
items = append(items, typed)
}
if nested, ok := typed["Items"]; ok {
visit(nested)
}
case []any:
for _, value := range typed {
visit(value)
}
case []map[string]any:
for _, value := range typed {
visit(value)
}
}
}
visit(payload)
return items
}
func mergedRemoteUserData(raw any, history *model.PlaybackHistory) map[string]any {
userData := map[string]any{}
if existing, ok := raw.(map[string]any); ok {
for key, value := range existing {
userData[key] = value
}
}
duration := history.DurationMs
position := history.PositionMs
percentage := float64(0)
if duration > 0 {
percentage = float64(position) / float64(duration) * 100
}
userData["PlaybackPositionTicks"] = position * 10_000
userData["Played"] = history.Completed
userData["PlayedPercentage"] = percentage
if history.Completed {
playCount := 0
switch value := userData["PlayCount"].(type) {
case int:
playCount = value
case int64:
playCount = int(value)
case float64:
playCount = int(value)
}
if playCount < 1 {
userData["PlayCount"] = 1
}
}
return userData
}
func (e *EmbyService) invalidateEmbyItemsCache(ctx context.Context) {
if e.cache != nil {
e.cache.DeletePrefix(ctx, "media:emby:")
}
}
func splitCSV(s string) []string {
+110
View File
@@ -0,0 +1,110 @@
package service
import (
"testing"
"github.com/ShukeBta/MMTL/internal/model"
)
func TestMergedRemoteUserData(t *testing.T) {
tests := []struct {
name string
raw any
history model.PlaybackHistory
position int64
played bool
percent float64
count int
preserve any
}{
{
name: "in-progress preserves remote fields",
raw: map[string]any{
"PlayCount": 2,
"Custom": "remote-value",
},
history: model.PlaybackHistory{PositionMs: 25_000, DurationMs: 100_000},
position: 250_000_000,
played: false,
percent: 25,
count: 2,
preserve: "remote-value",
},
{
name: "completed ensures a play count",
raw: map[string]any{"PlayCount": 0},
history: model.PlaybackHistory{PositionMs: 100_000, DurationMs: 100_000, Completed: true},
position: 1_000_000_000,
played: true,
percent: 100,
count: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
out := mergedRemoteUserData(tt.raw, &tt.history)
if got := out["PlaybackPositionTicks"]; got != tt.position {
t.Fatalf("PlaybackPositionTicks = %#v, want %d", got, tt.position)
}
if got := out["Played"]; got != tt.played {
t.Fatalf("Played = %#v, want %t", got, tt.played)
}
if got := out["PlayedPercentage"]; got != tt.percent {
t.Fatalf("PlayedPercentage = %#v, want %v", got, tt.percent)
}
if got := out["PlayCount"]; got != tt.count {
t.Fatalf("PlayCount = %#v, want %d", got, tt.count)
}
if tt.preserve != nil && out["Custom"] != tt.preserve {
t.Fatalf("Custom = %#v, want %#v", out["Custom"], tt.preserve)
}
})
}
}
func TestRemoteItemMapsFindsEnvelopeItems(t *testing.T) {
remoteID := EncodeEmbyRemoteID("mount-1", "item-1")
payload := map[string]any{
"Items": []any{
map[string]any{"Id": remoteID},
map[string]any{"Id": "local-item"},
},
}
items := remoteItemMaps(payload)
if len(items) != 2 {
t.Fatalf("item count = %d, want 2", len(items))
}
if items[0]["Id"] != remoteID {
t.Fatalf("first item ID = %#v, want %q", items[0]["Id"], remoteID)
}
}
func TestRecordProgressFallbacksToExistingHistoryDuration(t *testing.T) {
svc := newTestEmbyService(t)
remoteID := EncodeEmbyRemoteID("mount-test", "item-999")
user := &model.User{Username: "resume_test_user", Role: "user", Tier: "free", IsActive: true}
if err := svc.repo.User.Create(t.Context(), user); err != nil {
t.Fatalf("create user: %v", err)
}
// 先以有 runtimeTicks 写入首次进度
if err := svc.RecordProgress(t.Context(), user.ID, remoteID, 10_000_000, 100_000_000); err != nil {
t.Fatalf("first record progress: %v", err)
}
// 再次上报,但某些客户端此时发了 0 runtimeTicks
if err := svc.RecordProgress(t.Context(), user.ID, remoteID, 95_000_000, 0); err != nil {
t.Fatalf("second record progress: %v", err)
}
var hist model.PlaybackHistory
if err := svc.repo.DB.Where("user_id = ? AND media_id = ?", user.ID, remoteID).First(&hist).Error; err != nil {
t.Fatalf("find hist: %v", err)
}
if hist.DurationMs != 10_000 {
t.Fatalf("expected duration 10000ms, got %d", hist.DurationMs)
}
if !hist.Completed {
t.Fatalf("expected 95%% progress to be completed")
}
}
@@ -111,4 +111,3 @@ func safeZipTarget(destRoot, name string) (string, error) {
}
return targetAbs, nil
}
-1
View File
@@ -78,4 +78,3 @@ func TestStartInstallRejectsConcurrent(t *testing.T) {
svc.running = false
svc.mu.Unlock()
}
+4 -4
View File
@@ -50,11 +50,11 @@ func TestResolveSSLMaterial(t *testing.T) {
}
cases := []struct {
name string
name string
content string
path string
want string
err bool
path string
want string
err bool
}{
{name: "content only", content: certPEM, want: certPEM},
{name: "path only", path: path, want: certPEM},
+9
View File
@@ -84,3 +84,12 @@ func (p *ImageProxy) libraryRoots() []string {
p.libRootsAt = time.Now()
return p.libRootsCache
}
// Prune removes oldest cached images until disk usage is within the configured limit.
func (p *ImageProxy) Prune() (PruneImageCacheResult, error) {
if p.cfg == nil || p.cfg.Cache.ImagesMaxSizeMB <= 0 {
return PruneImageCacheResult{}, nil
}
maxBytes := int64(p.cfg.Cache.ImagesMaxSizeMB) * 1024 * 1024
return PruneImageCache(p.cacheDir, maxBytes)
}
+3 -3
View File
@@ -179,9 +179,9 @@ func TestManualSearchFallsBackToMovieFolderForGenericQuery(t *testing.T) {
if len(results) != 1 || results[0].TMDbID != 27205 {
t.Fatalf("manual search results=%#v, want folder fallback candidate; queries=%v", results, queries)
}
if len(queries) < 2 || queries[0] != "00000" || queries[len(queries)-1] != "inception" {
t.Fatalf("manual search queries=%v, want explicit query then folder fallback", queries)
}
if len(queries) < 2 || queries[0] != "00000" || queries[len(queries)-1] != "inception" {
t.Fatalf("manual search queries=%v, want explicit query then folder fallback", queries)
}
}
func TestManualSearchReturnsMovieFallbackForTVTypedTMDbSearch(t *testing.T) {
+34
View File
@@ -34,6 +34,40 @@ func (s *MediaService) mediaListCacheKey(libraryID string, libraryIDs []string,
return "media:list:" + hex.EncodeToString(sum[:])
}
func (s *MediaService) libraryPreviewCacheKey(libraries []model.Library, cardLimit int, filter repository.MediaQueryFilter) string {
libIDs := make([]string, len(libraries))
for i, lib := range libraries {
libIDs[i] = lib.ID
}
sort.Strings(libIDs)
allowed := append([]string(nil), filter.AllowedLibraryIDs...)
hidden := append([]string(nil), filter.HiddenLibraryIDs...)
sort.Strings(allowed)
sort.Strings(hidden)
sum := sha1.Sum([]byte(strings.Join([]string{
"preview",
strings.Join(libIDs, ","),
fmt.Sprintf("%d:%t", cardLimit, filter.IncludeNSFW),
strings.Join(allowed, ","),
strings.Join(hidden, ","),
}, "|")))
return "media:preview:" + hex.EncodeToString(sum[:])
}
func (s *MediaService) seriesCardsCacheKey(libraryID string, visibility MediaVisibility) string {
allowed := append([]string(nil), visibility.AllowedLibraryIDs...)
hidden := append([]string(nil), visibility.HiddenLibraryIDs...)
sort.Strings(allowed)
sort.Strings(hidden)
sum := sha1.Sum([]byte(strings.Join([]string{
libraryID,
fmt.Sprintf("%t", visibility.IncludeNSFW),
strings.Join(allowed, ","),
strings.Join(hidden, ","),
}, "|")))
return "media:series-cards:" + hex.EncodeToString(sum[:])
}
func (s *MediaService) mediaCacheTTLSeconds() int {
if s == nil || s.cfg == nil || s.cfg.Cache.MediaTTLSeconds < 1 {
return 15
+45 -14
View File
@@ -2,6 +2,7 @@ package service
import (
"context"
"time"
"github.com/ShukeBta/MMTL/internal/model"
"github.com/ShukeBta/MMTL/internal/repository"
@@ -14,6 +15,10 @@ type LibraryPreviewItem struct {
Cards []SeriesCard `json:"cards"`
}
type libraryPreviewCacheValue struct {
Items []LibraryPreviewItem `json:"items"`
}
// ListLibraries returns every library configured on the server.
func (s *MediaService) ListLibraries(ctx context.Context) ([]model.Library, error) {
return s.repo.Library.List(ctx)
@@ -29,6 +34,18 @@ func (s *MediaService) ListLibrariesWithPreview(ctx context.Context, libraries [
return out, nil
}
visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility)
filter := repository.MediaQueryFilter{
IncludeNSFW: visibility.IncludeNSFW,
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
}
cacheKey := s.libraryPreviewCacheKey(libraries, cardLimit, filter)
var cached libraryPreviewCacheValue
if s.cache != nil && s.cache.GetJSON(ctx, cacheKey, &cached) {
return cached.Items, nil
}
libIDs := make([]string, 0, len(libraries))
for i, lib := range libraries {
out[i] = LibraryPreviewItem{
@@ -39,13 +56,6 @@ func (s *MediaService) ListLibrariesWithPreview(ctx context.Context, libraries [
libIDs = append(libIDs, lib.ID)
}
visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility)
filter := repository.MediaQueryFilter{
IncludeNSFW: visibility.IncludeNSFW,
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
}
counts, err := s.repo.Media.CountByLibraries(ctx, libIDs, filter)
if err != nil {
return nil, err
@@ -64,15 +74,32 @@ func (s *MediaService) ListLibrariesWithPreview(ctx context.Context, libraries [
fetchCount = 200
}
recentByLibrary, err := s.repo.Media.ListRecentByLibraries(ctx, libIDs, fetchCount, filter)
if err != nil {
return nil, err
}
allPreviewItems := make([]model.Media, 0, len(libIDs)*fetchCount)
for i := range out {
if out[i].Total == 0 {
continue
}
items, _, err := s.repo.Media.ListByLibrariesFiltered(ctx, []string{out[i].ID}, 0, fetchCount, filter)
if err != nil {
items := recentByLibrary[out[i].ID]
if len(items) == 0 {
continue
}
allPreviewItems = append(allPreviewItems, items...)
}
s.attachLibraryMetadata(ctx, allPreviewItems)
for i := range out {
if out[i].Total == 0 {
continue
}
items := recentByLibrary[out[i].ID]
if len(items) == 0 {
continue
}
s.attachLibraryMetadata(ctx, items)
cards := groupMediaSeriesCards(items)
if len(cards) > cardLimit {
cards = cards[:cardLimit]
@@ -83,6 +110,10 @@ func (s *MediaService) ListLibrariesWithPreview(ctx context.Context, libraries [
out[i].Cards = cards
}
if s.cache != nil {
s.cache.SetJSON(ctx, cacheKey, libraryPreviewCacheValue{Items: out}, time.Duration(s.mediaCacheTTLSeconds())*time.Second)
}
return out, nil
}
@@ -117,10 +148,10 @@ func (s *MediaService) DeleteLibrary(ctx context.Context, id string) error {
if err := tx.Unscoped().Where("library_id = ?", id).Delete(&model.Media{}).Error; err != nil {
return err
}
if err := hardDeleteLibraryRoots(ctx, tx, id); err != nil {
return err
}
return tx.Unscoped().Delete(&model.Library{}, "id = ?", id).Error
if err := hardDeleteLibraryRoots(ctx, tx, id); err != nil {
return err
}
return tx.Unscoped().Delete(&model.Library{}, "id = ?", id).Error
})
if err == nil {
s.invalidateMediaCache(ctx)
+19 -1
View File
@@ -12,6 +12,11 @@ import (
"github.com/ShukeBta/MMTL/internal/model"
)
type seriesCardsCacheValue struct {
Cards []SeriesCard `json:"cards"`
Total int64 `json:"total"`
}
type SeriesCard struct {
Key string `json:"key"`
Rep model.Media `json:"rep"`
@@ -25,12 +30,25 @@ type seriesCardGroup struct {
}
func (s *MediaService) ListLibrarySeriesCards(ctx context.Context, libraryID string, visibility MediaVisibility) ([]SeriesCard, int64, error) {
visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility)
cacheKey := s.seriesCardsCacheKey(libraryID, visibility)
var cached seriesCardsCacheValue
if s.cache != nil && s.cache.GetJSON(ctx, cacheKey, &cached) {
return cached.Cards, cached.Total, nil
}
rows, _, err := s.listAllMediaVisible(ctx, libraryID, visibility)
if err != nil {
return nil, 0, err
}
cards := groupMediaSeriesCards(rows)
return cards, int64(len(cards)), nil
if cards == nil {
cards = []SeriesCard{}
}
total := int64(len(cards))
if s.cache != nil {
s.cache.SetJSON(ctx, cacheKey, seriesCardsCacheValue{Cards: cards, Total: total}, time.Duration(s.mediaCacheTTLSeconds())*time.Second)
}
return cards, total, nil
}
func (s *MediaService) ListRecentSeriesCards(ctx context.Context, limit int, visibility MediaVisibility) ([]SeriesCard, error) {
@@ -229,9 +229,9 @@ func (o *OrganizerService) replaceVersions(ctx context.Context, src string, exis
o.log.Warn("organize replace remove existing failed",
zap.String("path", e), zap.Error(err))
}
if o.repo != nil && o.repo.DB != nil {
_ = o.repo.DB.WithContext(ctx).Unscoped().Where("path = ?", e).Delete(&model.Media{}).Error
}
if o.repo != nil && o.repo.DB != nil {
_ = o.repo.DB.WithContext(ctx).Unscoped().Where("path = ?", e).Delete(&model.Media{}).Error
}
}
// Move staged file + sidecars into the final path.
if err := os.Rename(stage, dst); err != nil {
+23 -4
View File
@@ -19,8 +19,9 @@ import (
// PlaybackService bundles history / favourite / playlist business logic.
type PlaybackService struct {
log *zap.Logger
repo *repository.Container
log *zap.Logger
repo *repository.Container
remote *EmbyRemoteService
}
// NewPlaybackService is the constructor.
@@ -28,6 +29,14 @@ func NewPlaybackService(log *zap.Logger, repo *repository.Container) *PlaybackSe
return &PlaybackService{log: log, repo: repo}
}
// SetEmbyRemote wires the remote Emby service for hydrating mounted remote items.
func (p *PlaybackService) SetEmbyRemote(remote *EmbyRemoteService) *PlaybackService {
if p != nil {
p.remote = remote
}
return p
}
// ─── History ────────────────────────────────────────────────────────────────
// RecordProgress upserts the resume position for a (user, media) pair. A
@@ -84,9 +93,19 @@ func (p *PlaybackService) RecentHistory(ctx context.Context, userID string, limi
if m, ok := mediaByID[rows[i].MediaID]; ok {
media := m
items = append(items, HistoryItem{PlaybackHistory: rows[i], Media: &media})
} else {
items = append(items, HistoryItem{PlaybackHistory: rows[i]})
continue
}
if p.remote != nil && IsEmbyRemoteID(rows[i].MediaID) {
mountID, remoteID, _ := DecodeEmbyRemoteID(rows[i].MediaID)
if mount, acct, _ := p.remote.ResolveMount(ctx, mountID); mount != nil && acct != nil {
if rm, err := p.remote.RemoteMediaDetail(ctx, mount, acct, remoteID); err == nil && rm != nil {
media := *rm
items = append(items, HistoryItem{PlaybackHistory: rows[i], Media: &media})
continue
}
}
}
items = append(items, HistoryItem{PlaybackHistory: rows[i]})
}
return items, nil
}
+7
View File
@@ -110,6 +110,13 @@ func ApplyRuntimeSetting(cfg *config.Config, key, value string) {
cfg.App.SSLCertPath = strings.TrimSpace(value)
case "https.key_path":
cfg.App.SSLKeyPath = strings.TrimSpace(value)
case "cache.images_max_size_mb":
if n, err := strconv.Atoi(value); err == nil {
if n < 0 {
n = 0
}
cfg.Cache.ImagesMaxSizeMB = n
}
}
}
+18
View File
@@ -41,6 +41,8 @@ type SchedulerService struct {
cacheDir string
now func() time.Time
imagesMaxSizeMBProvider func() int
mu sync.Mutex
stopCh chan struct{}
jobs []*scheduledJob
@@ -59,6 +61,17 @@ func (s *SchedulerService) SetOrganizePipeline(pipeline *OrganizePipelineService
s.organizePipeline = pipeline
}
func (s *SchedulerService) SetImagesMaxSizeMBProvider(fn func() int) {
s.imagesMaxSizeMBProvider = fn
}
func (s *SchedulerService) imagesMaxSizeMB() int {
if s.imagesMaxSizeMBProvider != nil {
return s.imagesMaxSizeMBProvider()
}
return 0
}
// scheduledJob is one recurring task.
type scheduledJob struct {
name string
@@ -120,6 +133,11 @@ func (s *SchedulerService) Start(ctx context.Context) {
interval: 24 * time.Hour,
run: s.jobPurgeRecycleBin,
},
{
name: "image_cache_cleanup",
interval: 1 * time.Hour,
run: s.jobCleanImageCache,
},
}
for _, j := range s.jobs {
initialDelay := 15 * time.Second
+29
View File
@@ -2,6 +2,7 @@ package service
import (
"context"
"path/filepath"
"strconv"
"strings"
"time"
@@ -202,3 +203,31 @@ func isMissingTableErr(err error) bool {
}
return err == gorm.ErrInvalidDB
}
// jobCleanImageCache prunes image proxy cache files when disk usage exceeds the configured limit.
func (s *SchedulerService) jobCleanImageCache(ctx context.Context) error {
if s.cacheDir == "" {
return nil
}
maxMB := s.imagesMaxSizeMB()
if maxMB <= 0 {
return nil
}
imagesDir := filepath.Join(s.cacheDir, "images")
maxSizeBytes := int64(maxMB) * 1024 * 1024
res, err := PruneImageCache(imagesDir, maxSizeBytes)
if err != nil {
if s.log != nil {
s.log.Warn("scheduled image cache cleanup failed", zap.Error(err))
}
return err
}
if res.DeletedFiles > 0 && s.log != nil {
s.log.Info("scheduled image cache cleanup completed",
zap.Int("deleted_files", res.DeletedFiles),
zap.Int64("freed_bytes", res.FreedBytes),
zap.Int64("remaining_bytes", res.RemainingBytes),
)
}
return nil
}
+11 -11
View File
@@ -59,13 +59,13 @@ type Container struct {
Device *DeviceService
Cache *RuntimeCacheService
Sessions *SessionTrackerService
RecognitionWords *RecognitionWordsService
Danmaku *DanmakuService
Strm *StrmService
Database *DatabaseAdminService
FFTools *FFmpegToolsService
RecognitionWords *RecognitionWordsService
Danmaku *DanmakuService
Strm *StrmService
Database *DatabaseAdminService
FFTools *FFmpegToolsService
stopCtx context.Context
stopCtx context.Context
stopCancel context.CancelFunc
// ReloadHTTPServer 由 cmd/server 注入。HTTPS 相关设置保存后,handler
@@ -110,12 +110,12 @@ func (c *Container) Boot() {
c.Strm.Start(c.stopCtx)
}
// 启动刮削队列后台消费者
if c.Scraper != nil {
c.Scraper.Start(c.stopCtx)
}
// 启动刮削队列后台消费者
if c.Scraper != nil {
c.Scraper.Start(c.stopCtx)
}
// Mgo 保号规则巡检:默认关闭,由管理员通过 Telegram Bot 命令开启。
// Mgo 保号规则巡检:默认关闭,由管理员通过 Telegram Bot 命令开启。
// 每天触发一次评估;规则里的窗口可随机,不固定。
if c.Device != nil {
go c.runInactivitySweeper(c.stopCtx)
+32 -2
View File
@@ -2,11 +2,13 @@ package service
import (
"context"
"errors"
"strings"
"go.uber.org/zap"
"github.com/ShukeBta/MMTL/internal/config"
"github.com/ShukeBta/MMTL/internal/model"
"github.com/ShukeBta/MMTL/internal/repository"
)
@@ -103,12 +105,12 @@ func (b *serviceContainerBuilder) initContentServices() {
b.c.DLNA = NewDLNAService(b.log)
b.c.Storage = NewStorageService(b.log, b.repos)
b.c.Emby = NewEmbyService(b.cfg, b.log, b.repos)
b.c.EmbyRemote = NewEmbyRemoteService(b.cfg, b.log, b.repos, b.c.Crypto)
b.c.EmbyRemote = NewEmbyRemoteService(b.cfg, b.log, b.repos, b.c.Crypto).SetRuntimeCache(b.c.Cache)
b.c.Emby.SetEmbyRemote(b.c.EmbyRemote)
b.c.Backup = NewBackupService(b.cfg, b.log, b.repos.DB)
b.c.Media = NewMediaService(b.cfg, b.log, b.repos).SetRuntimeCache(b.c.Cache)
b.c.Stream = NewStreamService(b.cfg, b.log, b.repos, b.c.Transcoder)
b.c.Playback = NewPlaybackService(b.log, b.repos)
b.c.Playback = NewPlaybackService(b.log, b.repos).SetEmbyRemote(b.c.EmbyRemote)
b.c.Subtitle = NewSubtitleService(b.cfg, b.log, b.repos)
b.c.Profile = NewProfileService(b.log, b.repos)
b.c.Audit = NewAuditService(b.log, b.repos)
@@ -117,6 +119,28 @@ func (b *serviceContainerBuilder) initContentServices() {
b.c.FFTools = NewFFmpegToolsService(b.cfg, b.log, b.repos)
// 弹幕 hash 识别需要把 strm 指向解析成可拉取的直链/本地路径。
b.c.Danmaku.SetStrmResolver(b.c.Strm.ResolvePlay)
// 弹幕识别需要把远程 Emby 条目解析为 Media 元数据及可拉取前 16MB 的直链 URL。
if b.c.EmbyRemote != nil {
b.c.Danmaku.SetRemoteMediaResolver(func(ctx context.Context, encodedID string) (*model.Media, string, error) {
mountID, remoteID, ok := DecodeEmbyRemoteID(encodedID)
if !ok {
return nil, "", errors.New("invalid emby remote id")
}
mount, acct, err := b.c.EmbyRemote.ResolveMount(ctx, mountID)
if err != nil || mount == nil || acct == nil {
if err != nil {
return nil, "", err
}
return nil, "", errors.New("emby mount or account not found")
}
m, err := b.c.EmbyRemote.RemoteMediaDetail(ctx, mount, acct, remoteID)
if err != nil || m == nil {
return nil, "", err
}
streamURL, _ := b.c.EmbyRemote.WebStreamURL(ctx, acct, remoteID)
return m, streamURL, nil
})
}
}
func (b *serviceContainerBuilder) initAccessAndStorageServices() {
@@ -131,6 +155,12 @@ func (b *serviceContainerBuilder) initAccessAndStorageServices() {
)
b.c.Scheduler.SetTaskTracker(b.c.Tasks)
b.c.Scheduler.SetOrganizePipeline(b.c.OrganizePipeline)
b.c.Scheduler.SetImagesMaxSizeMBProvider(func() int {
if b.cfg == nil {
return 0
}
return b.cfg.Cache.ImagesMaxSizeMB
})
}
func (b *serviceContainerBuilder) initIdentityServices() {
+6 -2
View File
@@ -86,9 +86,9 @@ func playableSTRMTarget(ctx context.Context, repo *repository.Container, raw str
return STRMPlaybackEnabled(ctx, repo)
}
// isStrmMediaRow 判断媒体行是否为 .strm(远程直链)媒体:STRMURL 非空、
// IsStrmMediaRow 判断媒体行是否为 .strm(远程直链)媒体:STRMURL 非空、
// container=strm 或路径以 .strm 结尾。strm 媒体只能直连播放,禁止转码。
func isStrmMediaRow(m *model.Media) bool {
func IsStrmMediaRow(m *model.Media) bool {
if m == nil {
return false
}
@@ -101,6 +101,10 @@ func isStrmMediaRow(m *model.Media) bool {
return strings.HasSuffix(strings.ToLower(strings.TrimSpace(m.Path)), ".strm")
}
func isStrmMediaRow(m *model.Media) bool {
return IsStrmMediaRow(m)
}
func isHTTPPlaybackTarget(raw string) bool {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil || u == nil || !u.IsAbs() {
+10 -10
View File
@@ -22,18 +22,18 @@ func TestNormalizeCloudPlayTarget(t *testing.T) {
if parsed.IsAbs() || parsed.Host != "" {
t.Fatalf("normalized target should be relative, got %q", got)
}
if parsed.Query().Get("ref") != ref {
t.Fatalf("ref round-trip failed: %q", parsed.Query().Get("ref"))
}
if parsed.Query().Get("ref") != ref {
t.Fatalf("ref round-trip failed: %q", parsed.Query().Get("ref"))
}
strmStale := "http://bwg.linkmy.fun:1314/api/strm/play/cloud115/video.mkv?acct=abc&pickcode=123"
gotStrm := normalizeCloudPlayTarget(strmStale)
wantStrm := "/api/strm/play/cloud115/video.mkv?acct=abc&pickcode=123"
if gotStrm != wantStrm {
t.Fatalf("normalizeCloudPlayTarget(strm) = %q, want %q", gotStrm, wantStrm)
}
strmStale := "http://bwg.linkmy.fun:1314/api/strm/play/cloud115/video.mkv?acct=abc&pickcode=123"
gotStrm := normalizeCloudPlayTarget(strmStale)
wantStrm := "/api/strm/play/cloud115/video.mkv?acct=abc&pickcode=123"
if gotStrm != wantStrm {
t.Fatalf("normalizeCloudPlayTarget(strm) = %q, want %q", gotStrm, wantStrm)
}
// 非云盘播放 URL 保持原样(WebDAV/直链等)。
// 非云盘播放 URL 保持原样(WebDAV/直链等)。
passthrough := "https://dav.example.com/media/file.mkv"
if got := normalizeCloudPlayTarget(passthrough); got != passthrough {
t.Fatalf("non-cloud target should pass through, got %q", got)
+15
View File
@@ -609,11 +609,26 @@ func (s *StrmService) ClearCanceledUploadTasks(ctx context.Context) (int64, erro
return s.repo.StrmUpload.ClearCanceled(ctx)
}
// ClearDoneUploadTasks 清空全部已完成上传记录,返回删除数量。
func (s *StrmService) ClearDoneUploadTasks(ctx context.Context) (int64, error) {
return s.repo.StrmUpload.ClearDone(ctx)
}
// ClearFinishedUploadTasks 清空全部已完成与失败的上传记录,返回删除数量。
func (s *StrmService) ClearFinishedUploadTasks(ctx context.Context) (int64, error) {
return s.repo.StrmUpload.ClearFinished(ctx)
}
// RetryAllFailedDownloadTasks 批量重试所有失败下载任务,返回重新入队数量。
func (s *StrmService) RetryAllFailedDownloadTasks(ctx context.Context) (int64, error) {
return s.repo.StrmDownload.RetryAllFailed(ctx)
}
// RetryAllFailedUploadTasks 批量重试所有失败上传任务,返回重新入队数量。
func (s *StrmService) RetryAllFailedUploadTasks(ctx context.Context) (int64, error) {
return s.repo.StrmUpload.RetryAllFailed(ctx)
}
// CancelPendingDownloadTasks 批量取消所有排队下载任务,返回取消数量。
func (s *StrmService) CancelPendingDownloadTasks(ctx context.Context) (int64, error) {
return s.repo.StrmDownload.CancelPending(ctx)
+60
View File
@@ -1,8 +1,13 @@
package service
import (
"context"
"errors"
"testing"
"github.com/ShukeBta/MMTL/internal/model"
"github.com/ShukeBta/MMTL/internal/repository"
"go.uber.org/zap"
)
func TestIs115Blocked(t *testing.T) {
@@ -44,3 +49,58 @@ func TestIsHTTPDownloadFailure(t *testing.T) {
}
}
}
func TestStrmUploadTasksClearAndRetry(t *testing.T) {
db := newServiceTestDB(t, &model.StrmUploadTask{})
repos := repository.New(db)
svc := NewStrmService(nil, zap.NewNop(), repos, nil)
ctx := context.Background()
tasks := []*model.StrmUploadTask{
{Base: model.Base{ID: "task-pending"}, Status: model.StrmTaskPending, FileName: "1.nfo"},
{Base: model.Base{ID: "task-running"}, Status: model.StrmTaskRunning, FileName: "2.nfo"},
{Base: model.Base{ID: "task-done"}, Status: model.StrmTaskDone, FileName: "3.nfo"},
{Base: model.Base{ID: "task-failed"}, Status: model.StrmTaskFailed, FileName: "4.nfo", Error: "some error", RetryCount: 3},
{Base: model.Base{ID: "task-canceled"}, Status: model.StrmTaskCanceled, FileName: "5.nfo"},
}
for _, task := range tasks {
if err := db.Create(task).Error; err != nil {
t.Fatalf("failed to insert task: %v", err)
}
}
// 1. RetryAllFailedUploadTasks
retried, err := svc.RetryAllFailedUploadTasks(ctx)
if err != nil {
t.Fatalf("RetryAllFailedUploadTasks failed: %v", err)
}
if retried != 1 {
t.Fatalf("expected 1 retried task, got %d", retried)
}
var failedTask model.StrmUploadTask
if err := db.First(&failedTask, "id = ?", "task-failed").Error; err != nil {
t.Fatalf("failed to get task-failed: %v", err)
}
if failedTask.Status != model.StrmTaskPending || failedTask.Error != "" || failedTask.RetryCount != 0 {
t.Fatalf("task-failed was not reset properly: %+v", failedTask)
}
// 再次改为 failed 以便测试 ClearFinished
db.Model(&model.StrmUploadTask{}).Where("id = ?", "task-failed").Updates(map[string]any{"status": model.StrmTaskFailed})
// 2. ClearFinishedUploadTasks 应删除 done, failed, canceled 三条历史记录
deleted, err := svc.ClearFinishedUploadTasks(ctx)
if err != nil {
t.Fatalf("ClearFinishedUploadTasks failed: %v", err)
}
if deleted != 3 {
t.Fatalf("expected 3 deleted tasks (done, failed, canceled), got %d", deleted)
}
// 验证剩余的任务只有 pending 和 running
var count int64
db.Model(&model.StrmUploadTask{}).Count(&count)
if count != 2 {
t.Fatalf("expected 2 remaining tasks, got %d", count)
}
}
+1 -1
View File
@@ -91,7 +91,7 @@ type StrmService struct {
oauthSessions map[string]*strm115AuthSession
wafUntil time.Time // 115 风控/限流熔断截止时间(由 mu 保护)
downloadSem chan struct{} // 全局下载并发信号量:限制整个进程同时进行「换直链+下载」的并发数
downloadSem chan struct{} // 全局下载并发信号量:限制整个进程同时进行「换直链+下载」的并发数
downloadSemOnce sync.Once
}
+1 -1
View File
@@ -205,4 +205,4 @@ func (s *SubtitleService) ServeRaw(ctx context.Context, mediaID, sub string, w i
defer f.Close()
_, err = io.Copy(w, f)
return err
}
}
+1 -1
View File
@@ -20,7 +20,7 @@ const (
SystemUpdateCommandSettingKey = "system.update.command"
SystemUpdateComposeDirSettingKey = "system.update.compose_dir"
DefaultSystemUpdateImage = "ghcr.io/shukebta/mmtl:latest"
DefaultSystemUpdateImage = "ghcr.io/truewhile/mmtl:latest"
DefaultSystemUpdateWatchtowerImage = "containrrr/watchtower:latest"
)
+3 -3
View File
@@ -10,7 +10,7 @@ import (
func TestRenderSystemUpdateCommand(t *testing.T) {
status := SystemUpdateStatus{
Image: "ghcr.io/shukebta/mmtl:latest",
Image: "ghcr.io/truewhile/mmtl:latest",
WatchtowerImage: "containrrr/watchtower:latest",
ContainerID: "abc123def456",
ContainerName: "mmtl",
@@ -28,7 +28,7 @@ func TestRenderSystemUpdateCommand(t *testing.T) {
for _, want := range []string{
"containrrr/watchtower:latest",
"mmtl",
"ghcr.io/shukebta/mmtl:latest",
"ghcr.io/truewhile/mmtl:latest",
"abc123def456",
} {
if !strings.Contains(got, want) {
@@ -65,7 +65,7 @@ func TestComposeTargetInDirMatchesMMTLCompose(t *testing.T) {
if err := os.WriteFile(filepath.Join(dir, "docker-compose.yml"), []byte(`
services:
mmtl:
image: ghcr.io/shukebta/mmtl:latest
image: ghcr.io/truewhile/mmtl:latest
`), 0o600); err != nil {
t.Fatal(err)
}
BIN
View File
Binary file not shown.
+2
View File
@@ -8,6 +8,8 @@ export const embyAPI = {
updateMount: (id: string, payload: { name?: string; proxy_play?: boolean; enabled?: boolean }) =>
api.put<EmbyMount>(`/admin/emby/mounts/${id}`, payload).then((r) => r.data),
deleteMount: (id: string) => api.delete(`/admin/emby/mounts/${id}`).then((r) => r.data),
reorderMounts: (ids: string[]) =>
api.put<{ ok: boolean }>('/admin/emby/mounts/reorder', { ids }).then((r) => r.data),
listAccountViews: (accountId: string) =>
api.get<RemoteEmbyView[]>(`/admin/emby/accounts/${accountId}/views`).then((r) => r.data),
fullMountAccount: (accountId: string, proxy: boolean) =>
+9
View File
@@ -187,6 +187,15 @@ export const strmAPI = {
cancelPendingUploads: () =>
api.post<{ canceled: number }>('/admin/strm/uploads/cancel-pending').then((r) => r.data),
clearDoneUploads: () =>
api.post<{ deleted: number }>('/admin/strm/uploads/clear-done').then((r) => r.data),
clearFinishedUploads: () =>
api.post<{ deleted: number }>('/admin/strm/uploads/clear-finished').then((r) => r.data),
clearCanceledUploads: () =>
api.post<{ deleted: number }>('/admin/strm/uploads/clear-canceled').then((r) => r.data),
retryFailedUploads: () =>
api.post<{ retried: number }>('/admin/strm/uploads/retry-failed').then((r) => r.data),
}
+2 -2
View File
@@ -39,8 +39,8 @@ export function APIConfigsPanel() {
)}
{!loading && (
<div className="glass-panel overflow-hidden">
<table className="w-full text-left text-sm">
<div className="glass-panel table-scroll">
<table className="min-w-[640px] w-full text-left text-sm">
<thead className="border-b border-gray-200 text-xs uppercase tracking-wider text-sand-500">
<tr>
<th className="px-4 py-3">服务</th>
+10 -2
View File
@@ -7,6 +7,8 @@ import {
LayoutSidebars,
LayoutWorkspace,
} from './LayoutSections'
import { MobileBottomNav } from './MobileBottomNav'
import { shouldShowMobileBottomNav } from './layoutNavigation'
import { useLayoutPermissions } from './useLayoutPermissions'
import { useLayoutProfiles } from './useLayoutProfiles'
import { useLayoutSidebar } from './useLayoutSidebar'
@@ -51,14 +53,16 @@ export function Layout() {
const showSidebar = !isMediaView(location.pathname, location.search)
const hideSearch = location.pathname.startsWith('/settings')
const isPlayPage = location.pathname.startsWith('/play')
const showMobileBottomNav = shouldShowMobileBottomNav(location.pathname)
return (
<div className="flex h-screen w-screen overflow-hidden bg-[var(--app-bg)] text-[var(--app-text)] font-body select-none">
<div className="flex h-[100dvh] min-h-0 w-full overflow-hidden bg-[var(--app-bg)] text-[var(--app-text)] font-body select-none">
<LayoutSidebars
sidebar={sidebar}
isAdmin={permissions.isAdmin}
can={permissions.can}
showSidebar={showSidebar}
sidebarVariant={showSidebar ? 'admin' : 'media'}
/>
<div className="flex flex-1 flex-col min-w-0 overflow-hidden">
{!isPlayPage && (
@@ -72,9 +76,13 @@ export function Layout() {
onLogout={closeProfileAndLogout}
showSidebar={showSidebar}
hideSearch={hideSearch}
pathname={location.pathname}
/>
)}
<LayoutWorkspace routeKey={location.pathname} />
<LayoutWorkspace routeKey={location.pathname} showMobileBottomNav={showMobileBottomNav} />
{showMobileBottomNav && (
<MobileBottomNav onOpenMenu={() => sidebar.setIsMobileDrawerOpen(true)} />
)}
</div>
</div>
)
+33 -17
View File
@@ -1,11 +1,12 @@
import { useEffect, useRef, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { AnimatePresence, motion } from 'framer-motion'
import { Film, LoaderCircle, Menu, Search, Star, X } from 'lucide-react'
import { ArrowLeft, Film, LoaderCircle, Menu, Search, Star, X } from 'lucide-react'
import { imageURL } from '../api/client'
import { mediaAPI } from '../api/library'
import type { Media, PlayProfile, User } from '../types'
import { resolveHeaderBack } from './layoutNavigation'
import { LayoutThemeToggle } from './LayoutThemeToggle'
import { LayoutUserMenu } from './LayoutUserMenu'
import type { useLayoutProfiles } from './useLayoutProfiles'
@@ -29,6 +30,7 @@ type LayoutHeaderProps = {
onLogout: () => void
showSidebar?: boolean
hideSearch?: boolean
pathname?: string
}
export function LayoutHeader({
@@ -41,11 +43,26 @@ export function LayoutHeader({
onLogout,
showSidebar,
hideSearch,
pathname = '',
}: LayoutHeaderProps) {
const navigate = useNavigate()
const headerBack = resolveHeaderBack(pathname)
return (
<header className="relative flex h-20 shrink-0 items-center justify-between gap-4 border-b border-[var(--app-border)] bg-[var(--app-header-bg)] px-4 backdrop-blur-md z-30 md:px-8">
{/* Left: Mobile Menu button or Brand Logo */}
<div className="flex items-center gap-3 shrink-0">
<header className="relative z-30 flex h-16 shrink-0 items-center justify-between gap-2 border-b border-[var(--app-border)] bg-[var(--app-header-bg)] px-3 backdrop-blur-md sm:h-20 sm:gap-4 sm:px-4 md:px-8">
{/* Left: back / menu / brand */}
<div className="flex items-center gap-2 shrink-0 sm:gap-3">
{headerBack ? (
<button
type="button"
onClick={() => navigate(headerBack.to)}
className="inline-flex items-center gap-1.5 rounded-xl border border-[var(--app-border)] px-2.5 py-2 text-xs font-semibold text-[var(--app-subtle)] transition-colors hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] lg:hidden"
title={headerBack.label}
>
<ArrowLeft size={16} />
<span className="max-w-[4.5rem] truncate sm:max-w-none">{headerBack.label}</span>
</button>
) : null}
<button
onClick={onOpenMobileDrawer}
className="rounded-xl border border-[var(--app-border)] p-2.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] transition-colors lg:hidden"
@@ -53,12 +70,7 @@ export function LayoutHeader({
>
<Menu size={18} />
</button>
<Link
to="/"
className={`flex items-center gap-2.5 transition-transform hover:scale-105 ${
showSidebar ? 'lg:hidden' : ''
}`}
>
<Link to="/" className={`flex items-center gap-2.5 transition-transform hover:scale-105 ${showSidebar ? 'lg:hidden' : 'hidden sm:flex'}`}>
<img
src="/brand/logo-192.png"
alt="MMTL"
@@ -71,7 +83,7 @@ export function LayoutHeader({
</div>
{/* Middle: Search Box */}
<div className="flex-1 max-w-xl mx-auto">
<div className="flex min-w-0 flex-1 max-w-xl mx-auto">
{!hideSearch && <LayoutHeaderSearch />}
</div>
@@ -155,8 +167,8 @@ function LayoutHeaderSearch() {
<div ref={containerRef} className="relative w-full">
<div className="relative flex items-center">
<Search
size={16}
className="absolute left-3.5 text-[var(--app-muted)] pointer-events-none transition-colors group-focus-within:text-brand-500"
size={15}
className="absolute left-3 text-[var(--app-muted)] pointer-events-none transition-colors group-focus-within:text-brand-500 sm:left-3.5 sm:text-[16px]"
/>
<input
type="text"
@@ -169,8 +181,8 @@ function LayoutHeaderSearch() {
if (results.length > 0) setIsOpen(true)
}}
onKeyDown={handleKeyDown}
placeholder="搜索电影、剧集、演员…"
className="w-full h-10 pl-10 pr-9 rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] text-sm text-[var(--app-text)] placeholder:text-[var(--app-muted)] shadow-sm outline-none transition-all duration-200 focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20 focus:bg-[var(--app-panel-elevated)]"
placeholder="搜索媒体…"
className="w-full h-9 sm:h-10 pl-8 sm:pl-10 pr-8 sm:pr-9 rounded-xl sm:rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] text-xs sm:text-sm text-[var(--app-text)] placeholder:text-[var(--app-muted)] shadow-sm outline-none transition-all duration-200 focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20 focus:bg-[var(--app-panel-elevated)]"
/>
{loading ? (
<LoaderCircle size={15} className="absolute right-3.5 text-brand-500 animate-spin" />
@@ -288,8 +300,10 @@ function LayoutHeaderActions({
}: LayoutHeaderActionsProps) {
return (
<div className="flex shrink-0 items-center gap-2 sm:gap-3 md:gap-4">
<LayoutThemeToggle mode={themeMode} onChange={onThemeChange} />
<span className="hidden h-6 w-px bg-[var(--app-border)] sm:block" />
<div className="hidden items-center gap-3 sm:flex md:gap-4">
<LayoutThemeToggle mode={themeMode} onChange={onThemeChange} />
<span className="h-6 w-px bg-[var(--app-border)]" />
</div>
<LayoutUserMenu
user={user}
isOpen={isProfileOpen}
@@ -301,6 +315,8 @@ function LayoutHeaderActions({
onUseDefaultProfile={onUseDefaultProfile}
onSwitchProfile={onSwitchProfile}
onLogout={onLogout}
themeMode={themeMode}
onThemeChange={onThemeChange}
/>
</div>
)
+10 -2
View File
@@ -25,10 +25,12 @@ type LayoutSidebarsProps = Omit<
> & {
sidebar: LayoutSidebarState
showSidebar: boolean
sidebarVariant?: 'media' | 'admin'
}
type LayoutWorkspaceProps = {
routeKey: string
showMobileBottomNav?: boolean
}
export { LayoutHeader } from './LayoutHeaderSections'
@@ -78,6 +80,7 @@ export function LayoutSidebars({
isAdmin,
can,
showSidebar,
sidebarVariant = 'admin',
}: LayoutSidebarsProps) {
const content = (
<LayoutSidebarContent
@@ -87,6 +90,7 @@ export function LayoutSidebars({
can={can}
onToggleSidebar={sidebar.toggleSidebar}
onCloseMobileDrawer={() => sidebar.setIsMobileDrawerOpen(false)}
variant={sidebarVariant}
/>
)
@@ -116,7 +120,11 @@ export function LayoutSidebars({
)
}
export function LayoutWorkspace({ routeKey }: LayoutWorkspaceProps) {
export function LayoutWorkspace({ routeKey, showMobileBottomNav = false }: LayoutWorkspaceProps) {
const bottomPad = showMobileBottomNav
? 'pb-[calc(3.75rem+env(safe-area-inset-bottom,0px))] lg:pb-10'
: ''
if (routeKey.startsWith('/play')) {
return (
<main className="flex flex-1 h-full w-full overflow-hidden">
@@ -128,7 +136,7 @@ export function LayoutWorkspace({ routeKey }: LayoutWorkspaceProps) {
}
return (
<main className="flex-1 overflow-y-auto px-4 py-6 md:px-8 md:py-10">
<main className={clsx('flex-1 overflow-y-auto px-4 py-6 md:px-8 md:py-10', bottomPad)}>
<div className="max-w-7xl mx-auto">
<AnimatePresence mode="wait">
<motion.div

Some files were not shown because too many files have changed in this diff Show More