Compare commits

...

59 Commits

Author SHA1 Message Date
truewhile bad07f7add 优化支持0.5集 2026-09-27 13:52:17 +08:00
truewhile cb539afd2a 优化 2026-09-27 13:04:28 +08:00
truewhile 594166f696 bug处理 2026-09-25 20:30:49 +08:00
truewhile e0b07492a5 优化网页播放跳转 2026-09-25 13:24:10 +08:00
truewhile 3be6978e70 优化 2026-09-25 13:10:23 +08:00
truewhile 065652d758 优化 2026-09-24 22:27:09 +08:00
truewhile e273b5e87d 优化 2026-09-24 11:43:17 +08:00
truewhile c9c612df1b bug处理 2026-09-23 23:07:01 +08:00
truewhile e2cd32af53 处理yamby继续播放bug 2026-09-23 22:31:09 +08:00
truewhile 28485ed429 优化 2026-09-23 17:01:08 +08:00
truewhile 34ccf14cda 优化视频头尾跳过功能 2026-09-23 16:07:28 +08:00
truewhile 44fde6c5a0 优化 2026-09-23 13:53:50 +08:00
truewhile 94ef0116b1 添加跳过片头片尾功能 2026-09-23 10:22:13 +08:00
truewhile 016c6687e9 优化起播 2026-09-22 22:30:52 +08:00
truewhile b5ce4646cf 添加新功能,完善项目 2026-09-22 16:22:29 +08:00
truewhile 47899284a5 优化缩略图 2026-09-20 15:16:40 +08:00
truewhile c24ebe181d 优化缩略图 2026-09-20 14:13:49 +08:00
truewhile 9b2b592003 优化 2026-09-20 13:33:45 +08:00
truewhile 0c4668c156 优化 2026-09-19 21:30:32 +08:00
truewhile 474513f18a bug处理 2026-09-18 23:35:56 +08:00
truewhile 0810bfb234 bug处理 2026-09-18 22:52:09 +08:00
truewhile 61a7260eaf 优化,现有bug处理 2026-09-18 21:55:12 +08:00
truewhile 45f4763b0c 添加metatube部署文件 2026-09-18 12:00:04 +08:00
truewhile f4f57759ce 优化移动端播放 2026-09-18 11:28:33 +08:00
truewhile 6ec9adc976 优化移动端播放 2026-09-18 10:09:08 +08:00
truewhile f4abdf78e2 优化在线字幕 2026-09-17 23:53:22 +08:00
truewhile ae8db26c1e bug处理 2026-09-17 23:20:58 +08:00
truewhile 8017e994f4 优化 2026-09-17 22:57:41 +08:00
truewhile d56fb53a08 优化界面,优化多版本切换逻辑 2026-09-17 22:33:53 +08:00
truewhile 30ca94c79b 优化问题 2026-09-17 14:17:09 +08:00
truewhile 9c1f9a6e7e bug处理 2026-09-17 11:19:38 +08:00
truewhile ec7be38266 优化vr播放逻辑 2026-09-17 09:22:00 +08:00
truewhile ebf791c436 优化 2026-09-17 01:29:10 +08:00
truewhile 83c0b65a0b 优化360播放 2026-09-17 01:08:28 +08:00
truewhile 1153fe4191 添加VR360播放功能 2026-09-17 00:31:45 +08:00
truewhile 93866d169b 优化 2026-09-16 23:59:38 +08:00
truewhile d283d39008 优化 2026-09-16 23:36:03 +08:00
truewhile fc89d23867 优化 2026-09-16 22:20:23 +08:00
truewhile 1f14a1e85b 优化 2026-09-16 21:44:42 +08:00
truewhile f0a6b630c6 优化 2026-09-16 21:19:46 +08:00
truewhile c723664e46 优化 2026-09-16 18:34:24 +08:00
truewhile 5faa6975d0 优化标签 2026-09-16 13:50:06 +08:00
truewhile cbfae0252e t添加标签功能 2026-09-16 12:30:34 +08:00
truewhile 57ee482146 优化 2026-09-15 23:13:31 +08:00
truewhile a9b3110b2c 优化 2026-09-15 22:05:34 +08:00
truewhile ff86c2f597 yb显示 2026-09-15 18:50:50 +08:00
truewhile a5731fa963 优化显示 2026-09-15 18:26:20 +08:00
truewhile 531e45f677 优化 2026-09-15 16:48:42 +08:00
truewhile cd3f028f0b 修复bug 2026-09-15 16:12:59 +08:00
truewhile bd7b7a722b Merge pull request #34 from truewhile/cursor/hide-viewer-admin-ui
Cursor/hide viewer admin UI
2026-09-15 15:23:58 +08:00
truewhile 83deffcf45 修复 2026-09-15 15:21:43 +08:00
truewhile c74f6be250 Hide scrape and library admin controls from non-admin users.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 15:20:09 +08:00
truewhile c911a57467 bug处理 2026-09-15 09:58:47 +08:00
truewhile 1dbc3f8fe7 Merge branch 'main' of https://github.com/truewhile/MeBox 2026-09-15 00:00:54 +08:00
truewhile a563ee11d6 优化,添加滚动记忆 2026-09-15 00:00:35 +08:00
truewhile 215ebf6942 Merge pull request #33 from truewhile/cursor/fix-library-scrape-dialog-position
优化
2026-09-14 22:55:16 +08:00
truewhile e4c0e85eaa 优化 2026-09-14 22:54:09 +08:00
truewhile 60f05129c9 优化 2026-09-14 21:56:47 +08:00
truewhile fa4c90d47a bug处理 2026-09-14 21:40:13 +08:00
314 changed files with 29143 additions and 2594 deletions
+2
View File
@@ -0,0 +1,2 @@
'Get-Content' is not recognized as an internal or external command,
operable program or batch file.
+216
View File
@@ -0,0 +1,216 @@
# MeBox + MetaTube 一体部署 Docker Compose 部署文件
#
# 组件:
# MeBox + PostgreSQL + Redis + MetaTube
#
# 与 docker-compose.standard.yml 的区别:
# 额外内置一个 MetaTube 后端(番号元数据刮削 / 封面人脸裁剪),
# 并与 MeBox 共用同一个 PostgreSQL 实例,不需要再单独起一套数据库。
#
# 使用方式二选一:
# 1. 保存为 docker-compose.yml 后执行:
# docker compose up -d
# 2. 保留本文件名时执行:
# docker compose -f docker-compose.metatube.yml up -d
#
# 首次启动后需要做一次配置(只有这一处是手动的):
# 登录 MeBox → 设置 → 番号刮削:
# - 番号刮削引擎:MetaTube 后端服务,或 智能混合
# - MetaTube 服务端地址:http://metatube:8080
# - 访问 Token:留空(仅内部网络,未启用认证;如需认证两边填同一个值)
#
# 说明:
# - MetaTube 使用独立数据库 metatube,由 metatube-db-init 一次性容器自动创建;
# 和 MeBox 的 mebox 库共用同一个 PostgreSQL 实例,只是分成两个库。
# - MetaTube 不向宿主机映射端口,只在本 Compose 内部网络提供服务。
# - MeBox 故意不依赖 MetaTube:MetaTube 未就绪或挂掉时,智能混合模式会自动回退到内置刮削源。
# - 更新全部组件:
# docker compose -f docker-compose.metatube.yml pull
# docker compose -f docker-compose.metatube.yml up -d
# 保留本文件名时,管理面板「系统更新」需要把「Compose 安装目录」指向本文件所在目录,
# 或把更新命令改成带 -f docker-compose.metatube.yml 的形式。
#
# 默认账号:
# admin / admin123
services:
mebox:
image: ghcr.io/truewhile/mebox:latest
restart: unless-stopped
init: true
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
ports:
- "18080:8080"
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
# 程序运行数据:JWT 密钥、运行配置、旧 SQLite 迁移源。
- ./data:/data
# 缓存目录:海报缓存、临时文件等。通常不用备份。
- ./cache:/cache
# 媒体库目录。自动整理/重命名/入库需要读写权限。
# NAS 示例:source: /vol1/1000/Media
# Windows Docker Desktop 示例:source: D:/Media
# create_host_path=false 可以避免路径写错时 Docker 自动创建空文件夹。
- ./media: /media
environment:
TZ: Asia/Shanghai
PUID: "0"
PGID: "0"
MEBOX_APP_HOST: 0.0.0.0
MEBOX_APP_PORT: 8080
MEBOX_APP_WEB_DIR: /app/web/dist
MEBOX_APP_DATA_DIR: /data
MEBOX_LOGGING_LEVEL: info
MEBOX_LOGGING_FORMAT: console
MEBOX_LOGGING_OUTPUT_PATH: /data/logs
MEBOX_LOGGING_MAX_SIZE_MB: "20"
MEBOX_LOGGING_MAX_BACKUPS: "5"
MEBOX_LOGGING_MAX_AGE_DAYS: "30"
MEBOX_DATABASE_TYPE: postgres
MEBOX_DATABASE_DSN: postgres://mebox:mebox@postgres:5432/mebox?sslmode=disable
MEBOX_DATABASE_DB_PATH: /data/mebox.db
# Redis 只做热缓存,源数据仍在 PostgreSQL;Redis 丢失可自动重建。
MEBOX_CACHE_REDIS_URL: redis://redis:6379/0
MEBOX_CACHE_CACHE_DIR: /cache
MEBOX_UPDATE_IMAGE: ghcr.io/truewhile/mebox:latest
# 路径换算配置。左边宿主机真实路径要和 volumes 左边保持一致。
MEBOX_MEDIA_DIR: /media
MEBOX_MEDIA_CONTAINER_DIR: /media
MEBOX_DOWNLOAD_DIR: /downloads
MEBOX_DOWNLOAD_CONTAINER_DIR: /downloads
MEBOX_TRANSCODER_ENABLED: "true"
MEBOX_TRANSCODER_HARDWARE_ACCEL: "false"
MEBOX_TRANSCODER_REALTIME: "true"
MEBOX_TRANSCODER_THREADS: "2"
MEBOX_TRANSCODER_MAX_CONCURRENT: "1"
MEBOX_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120"
healthcheck:
test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"]
interval: 30s
timeout: 10s
retries: 5
start_period: 30s
logging:
driver: json-file
options:
max-size: "20m"
max-file: "3"
postgres:
image: postgres:16-alpine
# 首次部署允许拉取;日常更新请只 pull mebox。
# MetaTube 与 MeBox 共用本实例(不同数据库),无需第二套 PostgreSQL。
pull_policy: missing
restart: unless-stopped
environment:
POSTGRES_DB: mebox
POSTGRES_USER: mebox
POSTGRES_PASSWORD: mebox
TZ: Asia/Shanghai
volumes:
# 同时存放 mebox 与 metatube 两个数据库,备份这一个目录即可。
- ./postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U mebox -d mebox"]
interval: 10s
timeout: 5s
retries: 10
logging:
driver: json-file
options:
max-size: "20m"
max-file: "3"
# 一次性容器:确保 metatube 库存在。
# MetaTube 自己的 -db-auto-migrate 只建表不建库,所以必须先建库。
# 数据库已存在时它会直接退出,重复执行无副作用。
metatube-db-init:
image: postgres:16-alpine
pull_policy: missing
restart: "no"
depends_on:
postgres:
condition: service_healthy
environment:
PGPASSWORD: mebox
entrypoint: ["/bin/sh", "-c"]
command:
- |
psql -h postgres -U mebox -d mebox -tAc "SELECT 1 FROM pg_database WHERE datname='metatube'" | grep -q 1 || psql -h postgres -U mebox -d mebox -c "CREATE DATABASE metatube OWNER mebox"
logging:
driver: json-file
options:
max-size: "5m"
max-file: "1"
metatube:
image: ghcr.io/metatube-community/metatube-server:latest
restart: unless-stopped
depends_on:
metatube-db-init:
condition: service_completed_successfully
environment:
TZ: Asia/Shanghai
# 需要走代理时在这里填写,例如 http://192.168.1.2:7890
HTTP_PROXY: ""
HTTPS_PROXY: ""
# 不需要 ports:MeBox 通过内部网络访问 http://metatube:8080。
# 只有想让局域网内其他工具直连时才映射,且建议只映射到 127.0.0.1。
command:
- -dsn
- postgres://mebox:mebox@postgres:5432/metatube?sslmode=disable
- -port
- "8080"
- -db-auto-migrate
- -db-prepared-stmt
logging:
driver: json-file
options:
max-size: "20m"
max-file: "3"
redis:
image: redis:7-alpine
pull_policy: missing
restart: unless-stopped
command:
- redis-server
- --appendonly
- "yes"
- --maxmemory
- 256mb
- --maxmemory-policy
- allkeys-lru
volumes:
- ./redis:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 10
logging:
driver: json-file
options:
max-size: "20m"
max-file: "3"
+3 -20
View File
@@ -44,30 +44,13 @@ services:
# NAS 示例:source: /vol1/1000/Media
# Windows Docker Desktop 示例:source: D:/Media
# create_host_path=false 可以避免路径写错时 Docker 自动创建空文件夹。
- type: bind
source: ./media
target: /media
bind:
create_host_path: false
# 下载目录。需要和下载器保存路径保持一致。
# NAS 示例:source: /vol1/1000/Downloads
# Windows Docker Desktop 示例:source: D:/Downloads
- type: bind
source: ./downloads
target: /downloads
bind:
create_host_path: false
# 管理面板「系统更新」需要访问 Docker 引擎。
# 需要一键更新 Docker 镜像时取消下一行注释。
# - /var/run/docker.sock:/var/run/docker.sock
- ./media: /media
environment:
TZ: Asia/Shanghai
PUID: "1000"
PGID: "1000"
PUID: "0"
PGID: "0"
MEBOX_APP_HOST: 0.0.0.0
MEBOX_APP_PORT: 8080
-190
View File
@@ -1,190 +0,0 @@
# 【开源推荐】MeBox:把 NAS / 网盘 / 远程 Emby 统一家里的观影入口,Docker 一键部署
> 配图已托管在 GitHub 仓库(`raw.githubusercontent.com` 直链),发帖时可直接引用,或下载 `docs/tutorial-screenshots/` 后作为附件上传。
---
## 写在前面
给论坛的朋友们推荐一个我维护的开源项目 —— **MeBox**,一个面向 NAS 与家庭影音场景的**自托管私人媒体中心**(GPL-3.0,Go + React)。
GitHub:https://github.com/truewhile/MeBox
一句话介绍:**部署一个服务,同时获得媒体库后台、网盘 STRM 整理、Emby 客户端协议网关三件套。** 内置完整 Emby/Jellyfin 服务端协议实现——手机、电视、平板上的 Infuse、SenPlayer、Fileball、Emby/Jellyfin 官方客户端直接「添加 Emby 服务器」就能连,一套账号体系全搞定,Emby 老用户零学习成本。
项目 fork 自 MediaStationGo 并持续二开,围绕网盘播放、任务队列、远程挂载和权限体系做了大量增强。
---
## 它能解决什么问题?
家里看电影电视的痛点,MeBox 基本一把梭:
| 痛点 | MeBox 的解法 |
| --- | --- |
| 硬盘散落各处,海报墙乱七八糟 | 多根目录媒体库 + TMDb/Bangumi/Douban 自动刮削,海报墙、继续观看、多季剧集一应俱全 |
| 网盘资源看一部下一部太麻烦 | OpenList / CloudDrive2 / 115 / WebDAV 接入,STRM 同步 + 直链/302 播放,不占本地空间 |
| 已经有一台 Emby,出门还得开 App | **远程 Emby 挂载**:把远程 Emby 的媒体库直接挂进 MeBox 界面统一浏览 |
| 家人乱动设置、小孩看不该看的 | 多用户 + 有效期 + 成人内容开关 + 播放配置 PIN,细粒度权限 |
| 每个设备装一套专属 App 太折腾 | **完整兼容 Emby/Jellyfin 客户端**:Infuse、SenPlayer、Fileball、官方客户端按「添加 Emby 服务器」填地址 + MeBox 账号即可,海报墙、观看进度、多用户直接同步 |
---
## 特点一览
**1. 现代化 Web UI,海报墙开箱即用**
![登录页](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/01-login.png)
深色系登录页,默认账号 `admin / admin123`(首次登录请立即改密)。
![首页](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/02-home.png)
首页自带焦点推荐轮播 + 媒体库入口卡片,继续观看、最近添加直接呈现。
**2. 媒体库与刮削**
![媒体库总览](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/03-libraries.png)
20 个媒体库、1600+ 条目一眼尽收:每库自带封面拼贴、条目数统计,支持「全库修复+重刮」「刮削队列」批量处理。
![海报墙](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/04-library-posters.png)
库内海报墙带评分、集数角标,支持按最后集添加日期排序,点开即看。
**3. 详情页与多季管理**
![详情页](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/05-media-detail.png)
剧情简介、类型标签、多季分集(特别篇/第 1-N 季)、每集缩略图与时长;一键立即播放、调用外部播放器、加入收藏。
**4. Emby/Jellyfin 客户端无缝兼容**
这是我最想强调的一点:**MeBox 内置了完整的 Emby 服务端协议实现**。手机、电视、平板上的 Infuse、SenPlayer、Fileball,甚至 Emby/Jellyfin 官方客户端,都不需要任何插件或改造——按「添加 Emby 服务器」填入 `http://服务器IP:18080`,用 MeBox 账号登录,海报墙、观看进度、收藏、多用户权限全部无缝衔接。已经习惯 Emby 生态的朋友可以零成本迁移,家人只用电视端 App 也完全无感。
**5. 网页播放器 + 弹幕自动匹配**
![播放器与弹幕](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/06-player-danmu.png)
内置网页播放器支持 HLS 转码、字幕、播放配置档;**弹幕按剧名自动匹配全季分集**(截图中自动匹配到《一拳超人》39 集),屏幕占比/透明度/字号随意调,追新番体验直接拉满。
**6. 网盘 STRM:网盘当本地盘用**
![STRM 管理](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/07-strm-cloud.png)
添加网盘账号(**115 支持二维码扫码登录**)→ 添加同步目录 → 系统把网盘/本地目录里的视频生成 `.strm` 文件,元数据经下载/上传队列双向同步,播放走直链/302 不落盘。
**7. 远程 Emby 挂载(特色功能)**
![Emby 挂载](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/08-emby-mount.png)
已有远程 Emby 服务器?填一次账号,按需勾选要挂载的媒体库(支持同服务器多线路自动切换、直连开关、排序),远程库直接出现在 MeBox 首页,不必再开 Emby 客户端。
**8. 任务队列统一管理**
![任务队列](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/09-task-queue.png)
刮削 / 下载 / 上传三类任务统一看板,排队中、进行中、已匹配、失败分类计数,支持搜索与批量清理。
**9. 下载与自动整理**
![文件管理](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/11-file-manager.png)
配合任意下载器(qBittorrent、Transmission 等下载到本地目录即可),MeBox 定时自动整理入媒体库:智能分类子库、自动注册目的地媒体库、复制/移动/硬链/软链多种整理方式,命名规则可配。
**10. 多用户与权限**
![用户管理](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/12-user-admin.png)
管理员/普通用户分级、单实例用户数上限、账号有效期、成人内容开关、播放配置 PIN——给家人开号放心给。
**11. 运维省心**
![系统设置](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/10-settings.png)
FFmpeg/FFprobe 一键下载安装、转码与硬件加速开关、TMDb 语言、识别词、弹幕、Adult/NSFW 开关全在设置页分组管理;另有 DLNA 投屏、存储统计、海报墙聚合视图:
![海报墙聚合](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/13-poster-wall.png)
---
## 使用教程:从零到海报墙只要 5 步
### 第 1 步:Docker 一键部署
推荐 Docker Compose(仓库提供 4 份互相独立的完整模板,无需 `.env`):
```bash
mkdir -p MeBox && cd MeBox
# 最省心:单镜像 + 内置 SQLite
curl -fsSL https://raw.githubusercontent.com/truewhile/MeBox/main/docker-compose.simple.yml -o docker-compose.yml
# 多用户/大数据量可选 PostgreSQL 档、Redis 档、OpenSearch 档,见仓库 README「部署档位」
docker compose up -d
```
浏览器访问 `http://服务器IP:18080`,镜像:`ghcr.io/truewhile/mebox:latest`(amd64 / arm64 都有,也提供 Windows/Linux/macOS 单文件可执行程序,不想装 Docker 直接下载跑)。
### 第 2 步:登录并修改密码
默认账号 `admin / admin123`,登录后右上角头像 → 个人资料修改密码。
### 第 3 步:创建媒体库 + 扫库
后台 → 媒体库 → 管理媒体库,添加本地路径(Docker 部署记得填**容器内**路径,如 `/media/电影`,`volumes` 左侧挂宿主机真实目录)→ 执行扫库。
### 第 4 步:配置元数据刮削
系统设置 → 外部 API,填入 TMDb / Bangumi / Douban 等 API Key;媒体库页可对单个库「全库修复+重刮」,刮削进度在任务队列实时可见。
### 第 5 步(可选但强烈推荐):
- **网盘用户**:STRM 管理 → 添加网盘账号(115 可扫码)→ 添加同步目录 → 生成 STRM 后直链播放;
- **已有 Emby**:Emby 挂载 → 添加 Emby 账号 → 勾选要挂载的媒体库;
- **第三方播放器(Emby 客户端全兼容)**:Infuse / SenPlayer / Fileball / Emby、Jellyfin 官方客户端,按「添加 Emby 服务器」填 `http://服务器IP:18080`,用 MeBox 账号登录即可,原有使用习惯完全不变;
- **下载党**:qBittorrent 等任意下载器把视频下到下载目录,在文件管理里把它设为整理源,下完自动分类入库。
### 路径映射小抄(Docker 最常见坑)
```yaml
volumes:
- /vol1/1000/Media:/media # 左:宿主机真实路径;右:容器内路径(网页里填这个)
environment:
MEBOX_MEDIA_DIR: /vol1/1000/Media
MEBOX_MEDIA_CONTAINER_DIR: /media
```
硬链接要求同一文件系统/子卷,跨盘请改复制或软链。
---
## 部署档位怎么选?
| 档位 | 文件 | 组件 | 适合 |
| --- | --- | --- | --- |
| 极简 | `docker-compose.simple.yml` | 单镜像 + SQLite | 个人使用、低配设备 |
| 标准 | `docker-compose.yml` | + PostgreSQL | 多用户家庭共享 |
| 增强 | `docker-compose.standard.yml` | + Redis | 大媒体库高频访问 |
| 搜索 | `docker-compose.search.yml` | + OpenSearch | 超大库全文搜索 |
---
## 技术栈与致谢
- 后端:Go · Gin · GORM · SQLite/PostgreSQL · 可选 Redis / OpenSearch
- 前端:React 18 · Vite · TypeScript · Tailwind CSS · Zustand
- 部署:Docker Compose 多档模板,amd64/arm64 镜像 + 单文件可执行
感谢上游 [MediaStationGo](https://github.com/ShukeBta/MediaStationGo) 的奠基,网盘同步/STRM/整理部分参考了 [qmediasync](https://github.com/qicfan/qmediasync) 的思路。
---
## 链接
- GitHub:https://github.com/truewhile/MeBox
- Issue / PR:欢迎提 bug(附部署方式+复现步骤+日志)与功能建议
- License:GPL-3.0
觉得有用的话求个 Star ⭐,也欢迎论坛里的朋友反馈使用体验,我长期维护。
@@ -0,0 +1,36 @@
# Segment Coverage Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** IntroDB prewarm + same-season intro propagation + shorter miss TTL + playback-only duration probe (option C).
**Architecture:** Extend `MediaSegmentService` for merge/propagate/prewarm; add scheduler job `segment_prewarm`; repository queries for candidates and season siblings.
**Tech Stack:** Go, GORM/SQLite, existing SchedulerService + IntroDB client.
---
### Task 1: Negative cache TTL + ledgerFresh tests
**Files:** `internal/service/media_segment.go`, `internal/service/media_segment_test.go`
- Change `segmentMissingTTL` to 24h; update tests for fresh/stale miss.
### Task 2: Playback merge + propagation
**Files:** `media_segment.go`, `media_segment_repository.go`, `media_repository.go`, tests
- Constant `PropagatedSource = "propagated"`.
- `ListForPlayback` merges sources with priority manual > theintrodb > propagated.
- After successful IntroDB refresh with intro, propagate to same-season siblings (shared `tm_db_id` or `series_id`).
### Task 3: Prewarm API + scheduler job
**Files:** `media_segment.go`, `media_segment_repository.go`, `scheduler.go`, `scheduler_segment_jobs.go`, `service_builder.go`, tests
- `Prewarm(ctx, limit)` rate-limited refreshes.
- Job `segment_prewarm` every 6h.
### Task 4: Verify
- `go test` for affected packages.
@@ -0,0 +1,52 @@
# Segment Coverage Improvements (1–4, option C)
## Goal
Raise intro/outro skip availability without Chromaprint or full-library STRM probing.
## Scope (approved)
1. **IntroDB prewarm** — background job for all queryable media (TMDb + season/episode).
2. **Duration** — playback-path only (no full STRM scan); keep `ListForPlayback` → `EnsureAsync`.
3. **Same-season intro propagation** — copy intro to sibling episodes after a hit.
4. **Negative cache** — miss TTL 24h; 403/429/timeout must not write miss ledger.
## Non-goals
Chromaprint, manual segment UI, library-name filters, batch STRM duration scan.
## Behavior
### Prewarm (`segment_prewarm`)
- Scheduler job, interval 6h, initial delay 6h (avoid restart spike).
- **Opt-in via `segment.prewarm_enabled` (default off)** — system settings toggle「后台预热 IntroDB 片头片段」.
- Candidates: `tm_db_id > 0`, season/episode or movie, ledger missing or stale.
- Order: recently played first, then others.
- Rate: ~1 req/s, stop on context cancel; respect IntroDB 429 retry already in client.
- Reuse `MediaSegmentService` refresh + propagation.
- Manual `RunNow` can bypass the toggle (same pattern as organize).
### Propagation
- After TheIntroDB refresh finds at least one `intro` span, write the same intro window to same-season siblings that lack a `theintrodb` intro.
- Source tag: `propagated`.
- Do not propagate credits/preview/recap.
- Playback merge priority per kind: `manual` > `theintrodb` > `propagated`.
### Negative cache
- `segmentMissingTTL`: 24h (was 7d).
- `segmentFoundTTL`: 30d (unchanged).
- Provider errors (non-404) continue to skip ledger writes.
### Duration (C)
- No new batch probe job.
- Existing `defer ensureMediaProbe` on playback remains the only STRM duration path.
## Success criteria
- Prewarm increases `media_segment_fetches` over time without blocking play.
- One IntroDB intro hit can surface skip on sibling episodes via `propagated`.
- Misses are retried within ~24h (sooner if recently played and prewarm runs).
Binary file not shown.

Before

Width:  |  Height:  |  Size: 620 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 743 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 793 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 612 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 376 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 319 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

+3 -2
View File
@@ -44,9 +44,10 @@ 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.images_max_size_mb", 2000)
v.SetDefault("cache.images_originals_max_size_mb", 256)
v.SetDefault("cache.images_originals_ttl_hours", 168)
v.SetDefault("cache.memory_max_size_mb", DefaultCacheMemoryMaxSizeMB)
v.SetDefault("cache.cleanup_interval_min", 60)
v.SetDefault("cache.redis_url", "")
v.SetDefault("cache.redis_prefix", "mebox")
v.SetDefault("cache.media_ttl_seconds", 90)
+6
View File
@@ -47,6 +47,12 @@ func (c *Config) normalize() error {
if c.Cache.ImagesMaxSizeMB < 0 {
c.Cache.ImagesMaxSizeMB = 0
}
if c.Cache.ImagesOriginalsMaxSizeMB < 0 {
c.Cache.ImagesOriginalsMaxSizeMB = 0
}
if c.Cache.ImagesOriginalsTTLHours < 0 {
c.Cache.ImagesOriginalsTTLHours = 0
}
if c.Cache.MemoryMaxSizeMB <= 0 {
c.Cache.MemoryMaxSizeMB = DefaultCacheMemoryMaxSizeMB
}
+10 -8
View File
@@ -117,15 +117,17 @@ type LoggingConfig struct {
type CacheConfig struct {
CacheDir string `mapstructure:"cache_dir"`
ImagesMaxSizeMB int `mapstructure:"images_max_size_mb"`
// ImagesOriginalsMaxSizeMB 限制 cache/images 下原图子配额(MB)。原图只是
// 生成缩略图的原料,丢了可以重新回源;0 表示不单独限制(只受总量约束)。
ImagesOriginalsMaxSizeMB int `mapstructure:"images_originals_max_size_mb"`
// ImagesOriginalsTTLHours 是原图的保留时长(小时),超过即删除;0 表示
// 不按时间淘汰。缩短它能让原图缓存保持在一个滚动窗口内。
ImagesOriginalsTTLHours int `mapstructure:"images_originals_ttl_hours"`
// MemoryMaxSizeMB 限制进程内 L1 缓存总字节数,JSON/对象缓存共用该预算。
MemoryMaxSizeMB int `mapstructure:"memory_max_size_mb"`
MaxDiskUsageMB int `mapstructure:"max_disk_usage_mb"`
TTLHours int `mapstructure:"ttl_hours"`
AutoCleanup bool `mapstructure:"auto_cleanup"`
CleanupIntervalMin int `mapstructure:"cleanup_interval_min"`
RedisURL string `mapstructure:"redis_url"`
RedisPrefix string `mapstructure:"redis_prefix"`
MediaTTLSeconds int `mapstructure:"media_ttl_seconds"`
MemoryMaxSizeMB int `mapstructure:"memory_max_size_mb"`
RedisURL string `mapstructure:"redis_url"`
RedisPrefix string `mapstructure:"redis_prefix"`
MediaTTLSeconds int `mapstructure:"media_ttl_seconds"`
// EmbyLatestTTLSeconds 是 Emby「最新添加」(Items/Latest) 的缓存时长。
// 客户端刷新首页时会并发请求全部媒体库的 Latest(生产环境观察到 73 个
// 并发),缓存过短会让这批请求同时穿透并各自重建 payload,在低配主机
+26 -2
View File
@@ -20,6 +20,12 @@ type settingReq struct {
Value string `json:"value"`
}
// maskedSettingKeys 里的设置值绝不能被完整下发:它们是可用于对外操作的凭据。
// 下发脱敏值,保存时再靠 isMaskedSettingValue 还原为「保持原值」。
var maskedSettingKeys = map[string]bool{
service.SettingTelegramBotToken: true,
}
func listSettingsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
settings, err := svc.Repo.Setting.All(c.Request.Context())
@@ -27,10 +33,21 @@ func listSettingsHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
for i := range settings {
if maskedSettingKeys[settings[i].Key] {
settings[i].Value = service.MaskSecret(settings[i].Value)
}
}
c.JSON(http.StatusOK, settings)
}
}
// isMaskedSettingValue 识别「前端把脱敏值原样提交回来」的情况。此时必须保留
// 已存的真实值,否则一次保存就会把凭据覆盖成 ***。
func isMaskedSettingValue(value string) bool {
return strings.Contains(value, "***")
}
func updateSettingHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req settingReq
@@ -38,6 +55,11 @@ func updateSettingHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 脱敏值回传 == 用户没改这个凭据,保留库里已存的真实值。
if maskedSettingKeys[req.Key] && isMaskedSettingValue(req.Value) {
c.Status(http.StatusNoContent)
return
}
oldValue := ""
if req.Key == service.AdultLibraryIDsSettingKey {
oldValue, _ = svc.Repo.Setting.Get(c.Request.Context(), req.Key)
@@ -65,8 +87,10 @@ 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")
if req.Key == "cache.images_max_size_mb" || req.Key == "cache.images_originals_max_size_mb" || req.Key == "cache.images_originals_ttl_hours" {
if svc.Scheduler != nil {
_ = svc.Scheduler.RunNowAsync(c.Request.Context(), "image_cache_cleanup")
}
}
if req.Key == "cache.memory_max_size_mb" && svc.Cache != nil {
svc.Cache.SetMaxSizeMB(svc.Cfg.Cache.MemoryMaxSizeMB)
+66
View File
@@ -170,6 +170,72 @@ func TestUpdateDanmakuSettingsPersistsAllPlayerPreferences(t *testing.T) {
}
}
func TestUpdateDanmakuSettingsPersistsVr360GuideSeen(t *testing.T) {
svc := newDanmakuSettingsService(t)
// 默认未看过:配置接口必须先如实告知前端,否则首次操作说明会被跳过。
initial := svc.Danmaku.ConfigForUser(t.Context(), "user-1")
if initial.Vr360GuideSeen {
t.Fatal("vr360 guide should be unread for a fresh user")
}
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings",
`{"vr360_guide_seen":true}`, "user-1")
updateDanmakuSettingsHandler(svc)(c)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String())
}
var cfg service.DanmakuRenderConfig
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
t.Fatalf("decode: %v", err)
}
if !cfg.Vr360GuideSeen {
t.Fatalf("config should reflect the persisted guide flag: %+v", cfg)
}
user, err := svc.Repo.User.FindByID(t.Context(), "user-1")
if err != nil || user == nil {
t.Fatalf("read persisted user: %v", err)
}
if !user.PlayerVr360GuideSeen {
t.Fatalf("guide flag not persisted on the user: %+v", user)
}
}
// 播放倍速只作用于当前视频:接口既不下发也不保存它。老客户端仍可能带上
// playback_rate,服务端应当忽略而不是报错。
func TestDanmakuSettingsIgnorePlaybackRate(t *testing.T) {
svc := newDanmakuSettingsService(t)
c, w := newDanmakuSettingsContext(t, svc, http.MethodGet, "/danmaku/config", "", "user-1")
getDanmakuConfigHandler(svc)(c)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String())
}
if bytes.Contains(w.Body.Bytes(), []byte("playback_rate")) {
t.Fatalf("config must not expose playback rate: %s", w.Body.String())
}
c2, w2 := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings",
`{"volume":0.5,"playback_rate":1.5}`, "user-1")
updateDanmakuSettingsHandler(svc)(c2)
if w2.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", w2.Code, w2.Body.String())
}
if bytes.Contains(w2.Body.Bytes(), []byte("playback_rate")) {
t.Fatalf("settings response must not expose playback rate: %s", w2.Body.String())
}
user, err := svc.Repo.User.FindByID(t.Context(), "user-1")
if err != nil || user == nil {
t.Fatalf("read user: %v", err)
}
if user.PlayerVolume != 0.5 {
t.Fatalf("volume not persisted: %v", user.PlayerVolume)
}
}
func TestUpdateDanmakuSettingsRejectsInvalidSource(t *testing.T) {
svc := newDanmakuSettingsService(t)
+173
View File
@@ -0,0 +1,173 @@
package handler
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service"
)
// 设备管理接口。
//
// 普通用户只能操作自己的设备(路由挂在 /me 下,用户 ID 始终取自会话);
// 管理员通过 /admin/users/:id/devices 代管任意用户。两组接口共用同一份
// DeviceService,因此「谁上线过、谁被踢掉」只有一处事实来源。
// deviceListPayload 是设备列表的下发形状。Fingerprint 不外发:它是防共享
// 判定用的内部标识,暴露出去只会方便伪造。
type deviceListPayload struct {
Devices []devicePayload `json:"devices"`
}
type devicePayload struct {
ID string `json:"id"`
DeviceID string `json:"device_id"`
DeviceName string `json:"device_name,omitempty"`
Client string `json:"client,omitempty"`
LastIP string `json:"last_ip,omitempty"`
LastSeenAt string `json:"last_seen_at,omitempty"`
LastPlayAt string `json:"last_play_at,omitempty"`
Kicked bool `json:"kicked"`
Online bool `json:"online"`
Playing bool `json:"playing"`
Warnings int `json:"warnings"`
}
func toDevicePayload(d model.UserDevice) devicePayload {
out := devicePayload{
ID: d.ID,
DeviceID: d.DeviceID,
DeviceName: d.DeviceName,
Client: d.Client,
LastIP: d.LastIP,
Kicked: d.Kicked,
Online: d.Online,
Playing: d.Playing,
Warnings: d.Warnings,
}
if !d.LastSeenAt.IsZero() {
out.LastSeenAt = d.LastSeenAt.Format("2006-01-02T15:04:05Z07:00")
}
if d.LastPlayAt != nil && !d.LastPlayAt.IsZero() {
out.LastPlayAt = d.LastPlayAt.Format("2006-01-02T15:04:05Z07:00")
}
return out
}
func deviceListResponse(devices []model.UserDevice) deviceListPayload {
items := make([]devicePayload, 0, len(devices))
for _, d := range devices {
items = append(items, toDevicePayload(d))
}
return deviceListPayload{Devices: items}
}
// myDevicesHandler 返回当前会话用户的设备列表。
func myDevicesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
userID := sessionUserID(c)
devices, err := svc.Device.ListDevices(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, deviceListResponse(devices))
}
}
// myKickDeviceHandler 踢掉当前用户的一台设备。
func myKickDeviceHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
userID := sessionUserID(c)
deviceID := strings.TrimSpace(c.Param("deviceID"))
if deviceID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "device id required"})
return
}
if err := svc.Device.KickDevice(c.Request.Context(), userID, deviceID); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
}
// myKickAllDevicesHandler 踢掉当前用户的全部设备。
func myKickAllDevicesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
userID := sessionUserID(c)
if err := svc.Device.KickAllDevices(c.Request.Context(), userID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
}
// adminUserDevicesHandler 返回指定用户的设备列表。
func adminUserDevicesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
userID := strings.TrimSpace(c.Param("id"))
// FindByID 对「不存在」返回 (nil, nil),必须判空而不是判 error,
// 否则「用户不存在」会伪装成「该用户没有设备」的空列表。
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
}
devices, err := svc.Device.ListDevices(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, deviceListResponse(devices))
}
}
// adminKickUserDeviceHandler 由管理员踢掉指定用户的一台设备。
func adminKickUserDeviceHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
userID := strings.TrimSpace(c.Param("id"))
deviceID := strings.TrimSpace(c.Param("deviceID"))
if deviceID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "device id required"})
return
}
if err := svc.Device.KickDevice(c.Request.Context(), userID, deviceID); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
}
// adminKickAllUserDevicesHandler 由管理员踢掉指定用户的全部设备。
func adminKickAllUserDevicesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
userID := strings.TrimSpace(c.Param("id"))
if err := svc.Device.KickAllDevices(c.Request.Context(), userID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
}
// sessionUserID 读取会话用户 ID。调用方路由都挂在鉴权中间件之后,因此这里
// 只做类型断言兜底,不做权限判断。
func sessionUserID(c *gin.Context) string {
if v, ok := c.Get(middleware.CtxUserID); ok {
if s, ok := v.(string); ok {
return s
}
}
return ""
}
+232
View File
@@ -0,0 +1,232 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
// newDeviceTestEnv 搭一个只挂设备/Telegram 路由的最小环境,并预置两个用户,
// 用于验证「只能操作自己的设备」这条边界。
func newDeviceTestEnv(t *testing.T) (*gin.Engine, *service.Container) {
t.Helper()
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(&model.User{}, &model.UserDevice{}, &model.Setting{}); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
svc := &service.Container{Repo: repos, Log: zap.NewNop()}
svc.Device = service.NewDeviceService(zap.NewNop(), repos)
svc.Device.SetSessionTracker(service.NewSessionTrackerService(zap.NewNop()))
svc.Telegram = service.NewTelegramService(zap.NewNop(), repos)
const secret = "test-secret"
router := gin.New()
authed := router.Group("/api", func(c *gin.Context) {
// 测试里直接注入会话身份,绕开真实 JWT 解析。
if uid := c.GetHeader("X-Test-User"); uid != "" {
c.Set(middleware.CtxUserID, uid)
c.Set(middleware.CtxUserRole, c.GetHeader("X-Test-Role"))
}
c.Next()
})
authed.GET("/me/devices", myDevicesHandler(svc))
authed.POST("/me/devices/kick-all", myKickAllDevicesHandler(svc))
authed.POST("/me/devices/:deviceID/kick", myKickDeviceHandler(svc))
authed.GET("/me/telegram", getTelegramStatusHandler(svc))
authed.POST("/me/telegram/bind-code", startTelegramBindHandler(svc))
authed.DELETE("/me/telegram", unbindTelegramHandler(svc))
authed.GET("/admin/users/:id/devices", adminUserDevicesHandler(svc))
authed.POST("/admin/users/:id/devices/:deviceID/kick", adminKickUserDeviceHandler(svc))
_ = secret
return router, svc
}
func seedDeviceUsers(t *testing.T, svc *service.Container) {
t.Helper()
ctx := context.Background()
for _, id := range []string{"user-a", "user-b"} {
if err := svc.Repo.User.Create(ctx, &model.User{
Base: model.Base{ID: id}, Username: id, PasswordHash: "x", Role: "user", IsActive: true,
}); err != nil {
t.Fatal(err)
}
}
}
func doJSON(t *testing.T, router *gin.Engine, method, path, userID, role string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, path, nil)
req.Header.Set("X-Test-User", userID)
if role != "" {
req.Header.Set("X-Test-Role", role)
}
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w
}
// /me/devices 只能返回调用者自己的设备。
func TestMyDevicesScopedToCaller(t *testing.T) {
router, svc := newDeviceTestEnv(t)
seedDeviceUsers(t, svc)
ctx := context.Background()
svc.Device.RecordLogin(ctx, "user-a", "dev-a", "A-Phone", "Infuse", "1.1.1.1")
svc.Device.RecordLogin(ctx, "user-b", "dev-b", "B-Phone", "Infuse", "2.2.2.2")
w := doJSON(t, router, http.MethodGet, "/api/me/devices", "user-a", "user")
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
var payload struct {
Devices []struct {
DeviceID string `json:"device_id"`
} `json:"devices"`
}
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if len(payload.Devices) != 1 || payload.Devices[0].DeviceID != "dev-a" {
t.Fatalf("devices = %+v, want only dev-a", payload.Devices)
}
// 设备指纹属于内部判定标识,不能下发。
if strings.Contains(w.Body.String(), "fingerprint") {
t.Fatalf("response must not expose fingerprint: %s", w.Body.String())
}
}
// 踢别人的设备必须失败:/me 路由用会话身份,deviceID 属于他人时查不到。
func TestKickForeignDeviceFails(t *testing.T) {
router, svc := newDeviceTestEnv(t)
seedDeviceUsers(t, svc)
svc.Device.RecordLogin(context.Background(), "user-b", "dev-b", "B-Phone", "Infuse", "2.2.2.2")
w := doJSON(t, router, http.MethodPost, "/api/me/devices/dev-b/kick", "user-a", "user")
if w.Code == http.StatusNoContent {
t.Fatal("user-a must not be able to kick user-b's device")
}
}
func TestMyKickOwnDeviceSucceeds(t *testing.T) {
router, svc := newDeviceTestEnv(t)
seedDeviceUsers(t, svc)
svc.Device.RecordLogin(context.Background(), "user-a", "dev-a", "A-Phone", "Infuse", "1.1.1.1")
w := doJSON(t, router, http.MethodPost, "/api/me/devices/dev-a/kick", "user-a", "user")
if w.Code != http.StatusNoContent {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
}
// 管理员接口对不存在的用户返回 404,避免把「用户不存在」和「用户没有设备」
// 混成同一个空列表。
func TestAdminDevicesUnknownUserReturns404(t *testing.T) {
router, svc := newDeviceTestEnv(t)
seedDeviceUsers(t, svc)
w := doJSON(t, router, http.MethodGet, "/api/admin/users/nope/devices", "admin-1", "admin")
if w.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", w.Code)
}
}
func TestTelegramStatusAndBindCode(t *testing.T) {
router, svc := newDeviceTestEnv(t)
seedDeviceUsers(t, svc)
w := doJSON(t, router, http.MethodGet, "/api/me/telegram", "user-a", "user")
if w.Code != http.StatusOK {
t.Fatalf("status = %d", w.Code)
}
if !strings.Contains(w.Body.String(), `"bound":false`) {
t.Fatalf("body = %s, want bound=false", w.Body.String())
}
w = doJSON(t, router, http.MethodPost, "/api/me/telegram/bind-code", "user-a", "user")
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
var code struct {
Code string `json:"code"`
ExpiresIn int `json:"expires_in_seconds"`
}
if err := json.Unmarshal(w.Body.Bytes(), &code); err != nil {
t.Fatal(err)
}
if len(code.Code) != 6 {
t.Fatalf("code = %q, want 6 chars", code.Code)
}
if code.ExpiresIn <= 0 {
t.Fatalf("expires_in_seconds = %d, want > 0", code.ExpiresIn)
}
}
func TestAdminSettingsMasksBotToken(t *testing.T) {
router, svc := newDeviceTestEnv(t)
ctx := context.Background()
if err := svc.Repo.Setting.Set(ctx, service.SettingTelegramBotToken, "123456:AAHsecretTOKEN"); err != nil {
t.Fatal(err)
}
router.GET("/api/admin/settings", listSettingsHandler(svc))
router.PUT("/api/admin/settings", updateSettingHandler(svc))
w := doJSON(t, router, http.MethodGet, "/api/admin/settings", "admin-1", "admin")
if w.Code != http.StatusOK {
t.Fatalf("status = %d", w.Code)
}
if strings.Contains(w.Body.String(), "AAHsecretTOKEN") {
t.Fatalf("bot token leaked: %s", w.Body.String())
}
if !strings.Contains(w.Body.String(), "***") {
t.Fatalf("bot token should be masked: %s", w.Body.String())
}
}
// 把脱敏值原样提交回来时,必须保留库里真实 Token —— 否则一次保存就把凭据毁掉。
func TestSavingMaskedTokenKeepsRealValue(t *testing.T) {
router, svc := newDeviceTestEnv(t)
ctx := context.Background()
const real = "123456:AAHsecretTOKEN"
if err := svc.Repo.Setting.Set(ctx, service.SettingTelegramBotToken, real); err != nil {
t.Fatal(err)
}
router.PUT("/api/admin/settings", updateSettingHandler(svc))
body := `{"key":"telegram.bot_token","value":"12***EN"}`
req := httptest.NewRequest(http.MethodPut, "/api/admin/settings", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Test-User", "admin-1")
req.Header.Set("X-Test-Role", "admin")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNoContent {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
got, err := svc.Repo.Setting.Get(ctx, service.SettingTelegramBotToken)
if err != nil {
t.Fatal(err)
}
if got != real {
t.Fatalf("stored token = %q, want the original value preserved", got)
}
}
+7
View File
@@ -26,6 +26,13 @@ func embyUserID(c *gin.Context) string {
return ""
}
// embyEffectiveUserID 返回当前鉴权用户。收藏、续播、播放进度等用户态数据必须
// 绑定到登录身份;路径/Query 里的 UserId 只是 Emby URL 形态兼容,不能覆盖鉴权结果,
// 否则多用户会串到同一个 UserId 上。
func embyEffectiveUserID(c *gin.Context) string {
return embyUserID(c)
}
const embyCompatSessionTTL = 30 * time.Minute
type embyCompatSession struct {
+122
View File
@@ -0,0 +1,122 @@
package handler
import (
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/service"
)
// Emby 发现类接口的 handler:NextUp / Similar / Genres。
//
// 这三个接口此前返回空列表,导致第三方客户端首页「接下来播放」、详情页
// 「相似推荐」、按类型浏览全部为空白。它们必须始终返回 200 + 合法信封,
// 因为客户端在首页刷新时会并发请求,任何 4xx/5xx 都会被判定为服务端异常。
func embyNextUpHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
userID := embyScopedUserID(c)
if userID == "" {
c.JSON(http.StatusOK, embyEmptyItemsPayload())
return
}
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), ""))
// YamBy / Emby 进剧集详情会带 SeriesId 请求「本剧下一集」。
// 忽略该参数会把全站 NextUp 第一条塞进详情页「继续播放」。
// 注意:不要把普通 ParentId(媒体库)当成 SeriesId,否则首页 NextUp 会被滤空。
seriesID := firstQueryValue(c, "SeriesId", "seriesId", "seriesid")
if seriesID == "" {
if parentID := firstQueryValue(c, "ParentId", "parentId", "parentid"); parentID != "" {
if strings.HasPrefix(parentID, "msgo-series-") || service.IsEmbyRemoteID(parentID) {
seriesID = parentID
}
}
}
out, err := svc.Emby.NextUp(c.Request.Context(), userID, seriesID, limit)
if err != nil {
c.JSON(http.StatusOK, embyEmptyItemsPayload())
return
}
embyAttachRequestTokenToMediaSources(c, out)
c.JSON(http.StatusOK, out)
}
}
// embyShowNextUpHandler 处理 /Shows/{id}/NextUp:把路径上的剧集 ID 当作 SeriesId。
func embyShowNextUpHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
userID := embyScopedUserID(c)
if userID == "" {
c.JSON(http.StatusOK, embyEmptyItemsPayload())
return
}
seriesID := strings.TrimSpace(c.Param("id"))
if seriesID == "" || strings.EqualFold(seriesID, "NextUp") {
c.JSON(http.StatusOK, embyEmptyItemsPayload())
return
}
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), ""))
out, err := svc.Emby.NextUp(c.Request.Context(), userID, seriesID, limit)
if err != nil {
c.JSON(http.StatusOK, embyEmptyItemsPayload())
return
}
embyAttachRequestTokenToMediaSources(c, out)
c.JSON(http.StatusOK, out)
}
}
func embySimilarHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
mediaID := strings.TrimSpace(c.Param("id"))
if mediaID == "" {
c.JSON(http.StatusOK, embyEmptyItemsPayload())
return
}
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), ""))
out, err := svc.Emby.SimilarItems(c.Request.Context(), mediaID, embyEffectiveUserID(c), limit)
if err != nil {
c.JSON(http.StatusOK, embyEmptyItemsPayload())
return
}
embyAttachRequestTokenToMediaSources(c, out)
c.JSON(http.StatusOK, out)
}
}
func embyGenresHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
parentID := firstQueryValue(c, "ParentId", "parentId", "parentid")
out, err := svc.Emby.Genres(c.Request.Context(), embyEffectiveUserID(c), parentID)
if err != nil {
c.JSON(http.StatusOK, embyEmptyItemsPayload())
return
}
c.JSON(http.StatusOK, out)
}
}
// embyScopedUserID 解析「按用户请求」的 Emby 接口的生效用户。
//
// 路由上带 :userId 时(/Users/{uid}/Shows/NextUp),只允许查询自己:客户端
// 偶尔会带着别人的 id 请求,直接采信等于开放他人观看历史的读取。管理员同样
// 按自己处理,避免出现一条无人使用的越权路径。
func embyScopedUserID(c *gin.Context) string {
caller := embyEffectiveUserID(c)
requested := strings.TrimSpace(c.Param("userId"))
if requested == "" {
return caller
}
if requested == caller {
return caller
}
return ""
}
// embyEmptyItemsPayload 与 embyEmptyItemsHandler 保持同一形状。
func embyEmptyItemsPayload() gin.H {
return gin.H{"Items": []any{}, "TotalRecordCount": 0}
}
+372
View File
@@ -0,0 +1,372 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
// newEmbyDiscoveryEnv 搭一个跑在内存库上的 Emby 路由环境。
func newEmbyDiscoveryEnv(t *testing.T) (*gin.Engine, *service.Container, string) {
t.Helper()
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(
&model.User{}, &model.Library{}, &model.Media{}, &model.PlaybackHistory{},
&model.Setting{}, &model.Favorite{}, &model.UserDevice{},
); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
cfg := &config.Config{}
cfg.Secrets.JWTSecret = "test-secret"
svc := &service.Container{Repo: repos, Log: zap.NewNop()}
svc.Emby = service.NewEmbyService(cfg, zap.NewNop(), repos).
SetDiscovery(service.NewMediaDiscoveryService(zap.NewNop(), repos))
const userID = "user-1"
if err := repos.User.Create(context.Background(), &model.User{
Base: model.Base{ID: userID}, Username: "tester", PasswordHash: "x",
Role: "user", IsActive: true,
}); err != nil {
t.Fatal(err)
}
router := gin.New()
registerEmbyRoutes(router, cfg.Secrets.JWTSecret, svc)
return router, svc, userID
}
func seedEmbyLibrary(t *testing.T, svc *service.Container, typ string) string {
t.Helper()
lib := &model.Library{Name: "库-" + typ, Path: "/media/" + typ, Type: typ, Enabled: true}
if err := svc.Repo.Library.Create(context.Background(), lib); err != nil {
t.Fatal(err)
}
return lib.ID
}
func embyGet(t *testing.T, router *gin.Engine, path, token string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, path, nil)
if token != "" {
req.Header.Set("X-Emby-Token", token)
}
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w
}
func decodeItemsEnvelope(t *testing.T, body []byte) []map[string]any {
t.Helper()
var payload struct {
Items []map[string]any `json:"Items"`
TotalRecordCount int64 `json:"TotalRecordCount"`
}
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatalf("decode %s: %v", string(body), err)
}
return payload.Items
}
// NextUp 必须真的返回下一集,而不是空数组。
func TestEmbyNextUpReturnsNextEpisode(t *testing.T) {
router, svc, userID := newEmbyDiscoveryEnv(t)
libID := seedEmbyLibrary(t, svc, "tv")
watchedAt := time.Now().Add(-time.Hour)
for episode, watched := range map[int]bool{1: true, 2: false, 3: false} {
m := &model.Media{
LibraryID: libID, SeriesID: "series-1", Title: "剧一",
SeasonNum: 1, EpisodeNum: episode,
Path: "/media/tv/S1E" + string(rune('0'+episode)) + ".mkv",
}
if err := svc.Repo.DB.Create(m).Error; err != nil {
t.Fatal(err)
}
if watched {
h := &model.PlaybackHistory{
UserID: userID, MediaID: m.ID, PositionMs: 2000, DurationMs: 2000,
WatchedAt: watchedAt, Completed: true, // 第 1 集已看完
}
if err := svc.Repo.DB.Create(h).Error; err != nil {
t.Fatal(err)
}
}
}
w := embyGet(t, router, "/emby/Shows/NextUp", signedTestToken(t, "test-secret"))
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
items := decodeItemsEnvelope(t, w.Body.Bytes())
if len(items) != 1 {
t.Fatalf("items = %d, want 1 (body=%s)", len(items), w.Body.String())
}
if index, ok := items[0]["IndexNumber"].(float64); !ok || int(index) != 2 {
t.Fatalf("IndexNumber = %v, want 2 (body=%s)", items[0]["IndexNumber"], w.Body.String())
}
}
// 回归:用户在剧集详情页点播放、只看了几秒就退出(历史行 completed=false)后,
// Yamby 再次进入详情页带的 NextUp 仍要指向那一集本身,否则「继续播放」会跳到下一集。
func TestEmbyNextUpKeepsPartiallyWatchedEpisode(t *testing.T) {
router, svc, userID := newEmbyDiscoveryEnv(t)
libID := seedEmbyLibrary(t, svc, "tv")
episodeIDs := map[int]string{}
for ep := 1; ep <= 3; ep++ {
m := &model.Media{
LibraryID: libID, SeriesID: "series-1", Title: "剧一",
SeasonNum: 1, EpisodeNum: ep,
Path: "/media/tv/S1E" + strconv.Itoa(ep) + ".mkv",
}
if err := svc.Repo.DB.Create(m).Error; err != nil {
t.Fatal(err)
}
episodeIDs[ep] = m.ID
}
// 第 2 集播放了 3 秒后退出:有进度、未标记看完。
if err := svc.Repo.DB.Create(&model.PlaybackHistory{
UserID: userID, MediaID: episodeIDs[2], PositionMs: 3582, DurationMs: 1440064,
WatchedAt: time.Now(), Completed: false,
}).Error; err != nil {
t.Fatal(err)
}
w := embyGet(t, router, "/emby/Shows/NextUp?SeriesId=series-1&Limit=1", signedTestToken(t, "test-secret"))
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
items := decodeItemsEnvelope(t, w.Body.Bytes())
if len(items) != 1 {
t.Fatalf("items = %d, want 1 (body=%s)", len(items), w.Body.String())
}
if id, _ := items[0]["Id"].(string); id != episodeIDs[2] {
t.Fatalf("Id = %q, want the partially watched episode %q (body=%s)", id, episodeIDs[2], w.Body.String())
}
if index, ok := items[0]["IndexNumber"].(float64); !ok || int(index) != 2 {
t.Fatalf("IndexNumber = %v, want 2 (body=%s)", items[0]["IndexNumber"], w.Body.String())
}
}
// 没有历史时必须返回合法空信封,不能 404/500。
func TestEmbyNextUpEmptyWithoutHistory(t *testing.T) {
router, _, _ := newEmbyDiscoveryEnv(t)
w := embyGet(t, router, "/emby/Shows/NextUp", signedTestToken(t, "test-secret"))
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
if items := decodeItemsEnvelope(t, w.Body.Bytes()); len(items) != 0 {
t.Fatalf("items = %d, want 0", len(items))
}
}
// 小写别名路由同样要走到真实实现(客户端路径大小写并不统一)。
func TestEmbyNextUpLowercaseAlias(t *testing.T) {
router, svc, _ := newEmbyDiscoveryEnv(t)
_ = seedEmbyLibrary(t, svc, "tv")
w := embyGet(t, router, "/emby/shows/nextup", signedTestToken(t, "test-secret"))
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
}
// Similar 对不存在的条目返回空列表(客户端详情页会无条件请求)。
func TestEmbySimilarUnknownItemReturnsEmpty(t *testing.T) {
router, _, _ := newEmbyDiscoveryEnv(t)
w := embyGet(t, router, "/emby/Items/does-not-exist/Similar", signedTestToken(t, "test-secret"))
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
if items := decodeItemsEnvelope(t, w.Body.Bytes()); len(items) != 0 {
t.Fatalf("items = %d, want 0", len(items))
}
}
func TestEmbySimilarReturnsCandidates(t *testing.T) {
router, svc, _ := newEmbyDiscoveryEnv(t)
libID := seedEmbyLibrary(t, svc, "movie")
source := &model.Media{
LibraryID: libID, Title: "源片", Genres: "Action", Year: 2010, Rating: 8,
Path: "/media/movie/source.mkv",
}
if err := svc.Repo.DB.Create(source).Error; err != nil {
t.Fatal(err)
}
other := &model.Media{
LibraryID: libID, Title: "同类片", Genres: "Action", Year: 2011, Rating: 8,
Path: "/media/movie/other.mkv",
}
if err := svc.Repo.DB.Create(other).Error; err != nil {
t.Fatal(err)
}
w := embyGet(t, router, "/emby/Items/"+source.ID+"/Similar", signedTestToken(t, "test-secret"))
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
items := decodeItemsEnvelope(t, w.Body.Bytes())
if len(items) != 1 {
t.Fatalf("items = %d, want 1 (body=%s)", len(items), w.Body.String())
}
if name, _ := items[0]["Name"].(string); name != "同类片" {
t.Fatalf("Name = %q, want 同类片", name)
}
}
// Genres 必须返回真实类型与计数。
func TestEmbyGenresReturnsCounts(t *testing.T) {
router, svc, _ := newEmbyDiscoveryEnv(t)
libID := seedEmbyLibrary(t, svc, "movie")
for i, genres := range []string{"Action,Drama", "Action"} {
m := &model.Media{
LibraryID: libID, Title: "片" + string(rune('A'+i)), Genres: genres,
Path: "/media/movie/m" + string(rune('0'+i)) + ".mkv",
}
if err := svc.Repo.DB.Create(m).Error; err != nil {
t.Fatal(err)
}
}
w := embyGet(t, router, "/emby/Genres", signedTestToken(t, "test-secret"))
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
items := decodeItemsEnvelope(t, w.Body.Bytes())
if len(items) != 2 {
t.Fatalf("items = %d, want 2 (body=%s)", len(items), w.Body.String())
}
// 排序按计数降序:Action(2) 在前。
if name, _ := items[0]["Name"].(string); name != "Action" {
t.Fatalf("first Name = %q, want Action", name)
}
if count, ok := items[0]["ItemCount"].(float64); !ok || int(count) != 2 {
t.Fatalf("ItemCount = %v, want 2", items[0]["ItemCount"])
}
if id, _ := items[0]["Id"].(string); len(id) == 0 {
t.Fatal("genre item must carry a stable Id")
}
}
// 按别人的 userId 请求 NextUp 不允许泄露他人历史。
func TestEmbyNextUpRejectsForeignUserID(t *testing.T) {
router, _, _ := newEmbyDiscoveryEnv(t)
w := embyGet(t, router, "/emby/Users/someone-else/Shows/NextUp", signedTestToken(t, "test-secret"))
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
if items := decodeItemsEnvelope(t, w.Body.Bytes()); len(items) != 0 {
t.Fatalf("items = %d, want 0", len(items))
}
}
// YamBy 等客户端进入剧集详情会带 SeriesId 调 NextUp;必须只返回该剧的下一集,
// 不能回落成全站「继续观看」第一条,否则详情页播放会串到别的片子。
func TestEmbyNextUpFiltersBySeriesID(t *testing.T) {
router, svc, userID := newEmbyDiscoveryEnv(t)
libID := seedEmbyLibrary(t, svc, "tv")
recent := time.Now().Add(-time.Minute)
older := time.Now().Add(-2 * time.Hour)
seedSeries := func(seriesID, title string, watchedAt time.Time) (watchedID, nextID string) {
t.Helper()
for ep := 1; ep <= 3; ep++ {
m := &model.Media{
LibraryID: libID, SeriesID: seriesID, Title: title,
SeasonNum: 1, EpisodeNum: ep,
Path: "/media/tv/" + seriesID + "/S1E" + strconv.Itoa(ep) + ".mkv",
}
if err := svc.Repo.DB.Create(m).Error; err != nil {
t.Fatal(err)
}
switch ep {
case 1:
watchedID = m.ID
h := &model.PlaybackHistory{
UserID: userID, MediaID: m.ID, PositionMs: 2000, DurationMs: 2000,
WatchedAt: watchedAt, Completed: true, // 第 1 集已看完,下一集是 S1E2
}
if err := svc.Repo.DB.Create(h).Error; err != nil {
t.Fatal(err)
}
case 2:
nextID = m.ID
}
}
return watchedID, nextID
}
_, _ = seedSeries("series-hot", "热门剧", recent)
_, wantNext := seedSeries("series-cold", "目标剧", older)
token := signedTestToken(t, "test-secret")
global := embyGet(t, router, "/emby/Shows/NextUp?Limit=10", token)
if global.Code != http.StatusOK {
t.Fatalf("global status = %d body=%s", global.Code, global.Body.String())
}
if items := decodeItemsEnvelope(t, global.Body.Bytes()); len(items) < 2 {
t.Fatalf("global items = %d, want >= 2 (body=%s)", len(items), global.Body.String())
}
scoped := embyGet(t, router, "/emby/Shows/NextUp?SeriesId=series-cold&Limit=10", token)
if scoped.Code != http.StatusOK {
t.Fatalf("scoped status = %d body=%s", scoped.Code, scoped.Body.String())
}
items := decodeItemsEnvelope(t, scoped.Body.Bytes())
if len(items) != 1 {
t.Fatalf("scoped items = %d, want 1 (body=%s)", len(items), scoped.Body.String())
}
if id, _ := items[0]["Id"].(string); id != wantNext {
t.Fatalf("scoped Id = %q, want %q (body=%s)", id, wantNext, scoped.Body.String())
}
if seriesID, _ := items[0]["SeriesId"].(string); seriesID != "series-cold" {
t.Fatalf("scoped SeriesId = %q, want series-cold", seriesID)
}
empty := embyGet(t, router, "/emby/Shows/NextUp?SeriesId=series-never-watched", token)
if empty.Code != http.StatusOK {
t.Fatalf("empty status = %d body=%s", empty.Code, empty.Body.String())
}
if items := decodeItemsEnvelope(t, empty.Body.Bytes()); len(items) != 0 {
t.Fatalf("never-watched items = %d, want 0 (body=%s)", len(items), empty.Body.String())
}
pathScoped := embyGet(t, router, "/emby/Shows/series-cold/NextUp?Limit=10", token)
if pathScoped.Code != http.StatusOK {
t.Fatalf("path scoped status = %d body=%s", pathScoped.Code, pathScoped.Body.String())
}
pathItems := decodeItemsEnvelope(t, pathScoped.Body.Bytes())
if len(pathItems) != 1 {
t.Fatalf("path scoped items = %d, want 1 (body=%s)", len(pathItems), pathScoped.Body.String())
}
if id, _ := pathItems[0]["Id"].(string); id != wantNext {
t.Fatalf("path scoped Id = %q, want %q", id, wantNext)
}
}
+51 -36
View File
@@ -13,13 +13,7 @@ import (
func parseEmbyItemsParams(c *gin.Context) service.ItemsParams {
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "50"))
offset, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "StartIndex", "startIndex", "startindex"), "0"))
uid := c.Param("userId")
if uid == "" {
uid = firstQueryValue(c, "UserId", "userId", "userid")
}
if uid == "" {
uid = embyUserID(c)
}
uid := embyEffectiveUserID(c)
splitOpt := func(s string) []string {
if s == "" {
return nil
@@ -46,9 +40,28 @@ func parseEmbyItemsParams(c *gin.Context) service.ItemsParams {
SortOrder: firstQueryValue(c, "SortOrder", "sortOrder", "sortorder"),
Limit: limit,
StartIndex: offset,
SeasonIndex: parseEmbySeasonIndexQuery(c),
}
}
// parseEmbySeasonIndexQuery 读取客户端请求的季序号。
//
// Emby 客户端有两种表达方式:SeasonId(虚拟季 ID)与 Season / SeasonIndex
// (季序号,特别篇为 0)。两者都是合法入参,SeasonId 更精确。这里只解析季序号,
// 返回 nil 表示客户端没有按季过滤(区别于 Season=0 的特别篇)。
func parseEmbySeasonIndexQuery(c *gin.Context) *int {
raw := firstQueryValue(c, "Season", "season", "SeasonIndex", "seasonIndex", "seasonindex")
if raw == "" {
return nil
}
value, err := strconv.Atoi(raw)
if err != nil {
// 客户端偶尔传入季名称之类的非数字值;按「未过滤」处理,避免整季空结果。
return nil
}
return &value
}
func embyFirstNonEmptyString(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
@@ -73,10 +86,7 @@ func embyItemsHandler(svc *service.Container) gin.HandlerFunc {
func embyItemByIDHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
id := c.Param("id")
uid := c.Param("userId")
if uid == "" {
uid = embyUserID(c)
}
uid := embyEffectiveUserID(c)
out, err := svc.Emby.Item(c.Request.Context(), id, uid)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -106,13 +116,7 @@ func embyUserItemByIDHandler(svc *service.Container) gin.HandlerFunc {
func embyLatestItemsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid := c.Param("userId")
if uid == "" {
uid = firstQueryValue(c, "UserId", "userId", "userid")
}
if uid == "" {
uid = embyUserID(c)
}
uid := embyEffectiveUserID(c)
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "20"))
out, err := svc.Emby.LatestItems(c.Request.Context(), uid, firstQueryValue(c, "ParentId", "parentId", "parentid"), limit)
if err != nil {
@@ -126,15 +130,12 @@ func embyLatestItemsHandler(svc *service.Container) gin.HandlerFunc {
func embyResumeItemsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid := c.Param("userId")
if uid == "" {
uid = firstQueryValue(c, "UserId", "userId", "userid")
}
if uid == "" {
uid = embyUserID(c)
}
uid := embyEffectiveUserID(c)
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "20"))
out, err := svc.Emby.ResumeItems(c.Request.Context(), uid, limit)
startIndex, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "StartIndex", "startIndex", "startindex"), "0"))
// ParentId / SeriesId 收窄到当前库或当前剧,避免详情页继续播放串到全站历史。
parentID := firstQueryValue(c, "ParentId", "parentId", "parentid", "SeriesId", "seriesId", "seriesid")
out, err := svc.Emby.ResumeItems(c.Request.Context(), uid, parentID, limit, startIndex)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -147,13 +148,7 @@ func embyResumeItemsHandler(svc *service.Container) gin.HandlerFunc {
func embyItemsCountsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if svc != nil && svc.Emby != nil {
uid := firstQueryValue(c, "UserId", "userId")
if uid == "" {
uid = c.Param("userId")
}
if uid == "" {
uid = embyUserID(c)
}
uid := embyEffectiveUserID(c)
out, err := svc.Emby.ItemCounts(c.Request.Context(), uid)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -209,7 +204,7 @@ func embySaveDisplayPreferencesHandler(_ *service.Container) gin.HandlerFunc {
func embyShowSeasonsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
params := service.ItemsParams{
UserID: firstQueryValue(c, "UserId", "userId"),
UserID: embyEffectiveUserID(c),
ParentID: c.Param("id"),
Limit: 500,
}
@@ -226,15 +221,35 @@ func embyShowSeasonsHandler(svc *service.Container) gin.HandlerFunc {
func embyShowEpisodesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
parentID := firstQueryValue(c, "SeasonId", "seasonId")
// 客户端常用季序号而不是虚拟季 ID 请求剧集。缺了这层过滤,
// /Shows/{id}/Episodes?Season=2 会把整部剧的所有季都返回。
seasonIndex := parseEmbySeasonIndexQuery(c)
if parentID == "" {
parentID = c.Param("id")
} else {
// SeasonId 已经限定了具体季,忽略同时传来的季序号,避免两者
// 不一致时把结果过滤成空集。
seasonIndex = nil
}
// Emby 客户端按 StartIndex/Limit 分页拉取分集,并在已收条目数小于
// TotalRecordCount 时继续请求下一页。忽略这两个参数会让客户端永远
// 停在第一页、反复重发同一请求(现象是分集列表一直加载不出来)。
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "500"))
if limit <= 0 {
limit = 500
}
startIndex, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "StartIndex", "startIndex", "startindex"), "0"))
if startIndex < 0 {
startIndex = 0
}
params := service.ItemsParams{
UserID: firstQueryValue(c, "UserId", "userId"),
UserID: embyEffectiveUserID(c),
ParentID: parentID,
IncludeItemTypes: []string{"Episode"},
Recursive: true,
Limit: 500,
Limit: limit,
StartIndex: startIndex,
SeasonIndex: seasonIndex,
}
out, err := svc.Emby.Items(c.Request.Context(), params)
if err != nil {
+151
View File
@@ -0,0 +1,151 @@
// Package handler — Emby / Jellyfin 媒体分段(片头、片尾)兼容接口。
//
// GET /MediaSegments/{itemId}
// GET /Items/{itemId}/MediaSegments
// GET /Users/{userId}/Items/{itemId}/MediaSegments
//
// 契约对齐 Jellyfin 10.10 引入的 Media Segments API(Emby 采用同一形状),
// 也是 TheIntroDB 官方 Jellyfin 插件走的同一条路:
//
// QueryResult<MediaSegmentDto> = {"Items": [...], "TotalRecordCount": N}
// MediaSegmentDto = {"Id", "ItemId", "Type", "StartTicks", "EndTicks"}
//
// 时间是 .NET ticks(1 tick = 100ns,即每秒 10,000,000、每毫秒 10,000);
// Type 是枚举名字符串 Intro / Outro / Recap / Preview / Commercial。
package handler
import (
"context"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service"
)
// 第三方客户端会在起播前后同步请求分段,不能被一次外网抓取无限拖住。超时后
// 退回已有缓存(可能为空),请求本身永远不失败。
const embyMediaSegmentsFetchBudget = 5 * time.Second
// 1 秒 = 10,000,000 ticks => 1 毫秒 = 10,000 ticks。
const embyTicksPerMillisecond int64 = 10_000
func embyMediaSegmentsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
// 未知条目、虚拟剧集/季、远程 Emby 挂载、以及当前不可见的内容一律返回
// 空结果而不是 404:客户端会把 404 判成「条目损坏」(同 emby_routes.go
// 里 AdditionalParts 的说明),而「没有可跳过的片段」本来就是个合法状态。
empty := gin.H{"Items": []any{}, "TotalRecordCount": 0}
if svc == nil || svc.Repo == nil || svc.Segments == nil {
c.JSON(http.StatusOK, empty)
return
}
id := c.Param("id")
// 远程 Emby 挂载的条目是上游库的投影,本地没有可查询的外部 ID 关联。
if service.IsEmbyRemoteID(id) {
c.JSON(http.StatusOK, empty)
return
}
m, err := svc.Repo.Media.FindByID(c.Request.Context(), id)
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
c.JSON(http.StatusOK, empty)
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), embyMediaSegmentsFetchBudget)
defer cancel()
rows, listErr := svc.Segments.ListForPlayback(ctx, m)
if listErr != nil && svc.Log != nil {
svc.Log.Debug("emby media segments lookup failed",
zap.String("media_id", m.ID), zap.Error(listErr))
}
items := embySegmentItems(m, rows, embyRequestedSegmentTypes(c))
c.JSON(http.StatusOK, gin.H{"Items": items, "TotalRecordCount": len(items)})
}
}
// embySegmentItems 把库内片段转换成 MediaSegmentDto 列表。
func embySegmentItems(m *model.Media, rows []model.MediaSegment, want map[string]bool) []gin.H {
// 末段在库内用 end_ms = 0 表示「一直到片尾」(TheIntroDB 对片尾返回 end_ms: null),
// 这里必须换算成真实结束时间;拿不到时长就丢弃该分段,否则会给出一个零长度区间,
// 客户端要么忽略要么画出一个错误的跳转点。
durationMs := int64(m.DurationSec) * 1000
items := make([]gin.H, 0, len(rows))
for _, row := range rows {
typeName := embySegmentTypeName(row.Kind)
if typeName == "" {
continue
}
if len(want) > 0 && !want[typeName] {
continue
}
endMs := row.EndMs
if endMs <= 0 {
if durationMs <= 0 {
continue
}
endMs = durationMs
}
if endMs <= row.StartMs {
continue
}
items = append(items, gin.H{
"Id": row.ID,
"ItemId": m.ID,
"Type": typeName,
"StartTicks": row.StartMs * embyTicksPerMillisecond,
"EndTicks": endMs * embyTicksPerMillisecond,
})
}
return items
}
// embySegmentTypeName 把库内 kind 映射成 Emby/Jellyfin 的 MediaSegmentType 名字。
// 库内的 credits 取自 TheIntroDB 的字段名,在 Emby 一侧对应 Outro。
func embySegmentTypeName(kind string) string {
switch kind {
case model.SegmentKindIntro:
return "Intro"
case model.SegmentKindRecap:
return "Recap"
case model.SegmentKindCredits:
return "Outro"
case model.SegmentKindPreview:
return "Preview"
default:
return ""
}
}
// embyRequestedSegmentTypes 解析 includeSegmentTypes(Jellyfin 的过滤参数)。
// 支持重复参数与逗号分隔两种写法;返回空集合表示不过滤。
//
// 只认枚举名字符串。数字枚举虽然 ASP.NET 模型绑定也接受,但各家定义的顺序并
// 不一致,猜错会把过滤结果算错;认不出来时按「不过滤」处理,返回的是超集,
// 客户端自己仍会再过滤一次。
func embyRequestedSegmentTypes(c *gin.Context) map[string]bool {
raw := make([]string, 0, 4)
for _, key := range []string{"includeSegmentTypes", "IncludeSegmentTypes", "includesegmenttypes"} {
raw = append(raw, c.QueryArray(key)...)
}
want := make(map[string]bool, len(raw))
for _, value := range raw {
for _, part := range strings.Split(value, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
for _, name := range []string{"Intro", "Outro", "Recap", "Preview", "Commercial"} {
if strings.EqualFold(part, name) {
want[name] = true
}
}
}
}
return want
}
@@ -0,0 +1,217 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
type embySegmentsPayload struct {
Items []struct {
ID string `json:"Id"`
ItemID string `json:"ItemId"`
Type string `json:"Type"`
StartTicks int64 `json:"StartTicks"`
EndTicks int64 `json:"EndTicks"`
} `json:"Items"`
TotalRecordCount int `json:"TotalRecordCount"`
}
// 电影:intro 有明确结束点;credits 的 end_ms 为 null(库内落成 0),
// 必须用媒体时长补齐 —— 这是最容易写错的一处。
const embySegmentsProviderBody = `{"tmdb_id":27205,"type":"movie","intro":[{"start_ms":null,"end_ms":38000}],"credits":[{"start_ms":6480000,"end_ms":null}]}`
const (
embySegmentsMovieDurationSec = 8880
embySegmentsTicksPerSecond = 10_000_000
)
func newEmbySegmentsTestRouter(t *testing.T, durationSec int) (*gin.Engine, string, *int32) {
t.Helper()
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(model.AllModels()...); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
if err := repos.User.Create(t.Context(), &model.User{
Base: model.Base{ID: "user-1"},
Username: "tester",
PasswordHash: "x",
Role: "admin",
Tier: "plus",
IsActive: true,
}); err != nil {
t.Fatal(err)
}
lib := model.Library{Base: model.Base{ID: "lib-movies"}, Name: "电影", Path: "D:\\media\\movies", Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
if err := db.Create(&model.Media{
Base: model.Base{ID: "movie-1"},
LibraryID: lib.ID,
Title: "Inception",
Path: "D:\\media\\movies\\Inception.mkv",
DurationSec: durationSec,
TMDbID: 27205,
}).Error; err != nil {
t.Fatal(err)
}
var calls int32
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
atomic.AddInt32(&calls, 1)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(embySegmentsProviderBody))
}))
t.Cleanup(provider.Close)
segments := service.NewMediaSegmentService(zap.NewNop(), repos).
SetIntroDB(service.NewIntroDBService(zap.NewNop()).SetBaseURL(provider.URL))
const secret = "test-secret"
router := gin.New()
registerEmbyRoutes(router, secret, &service.Container{
Repo: repos,
Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos),
Segments: segments,
Log: zap.NewNop(),
})
return router, secret, &calls
}
func embySegmentsRequest(t *testing.T, router *gin.Engine, secret, path string) embySegmentsPayload {
t.Helper()
req := httptest.NewRequest(http.MethodGet, path, nil)
req.Header.Set("X-Emby-Token", signedTestToken(t, secret))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("%s status = %d body=%s", path, w.Code, w.Body.String())
}
var payload embySegmentsPayload
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("%s decode: %v", path, err)
}
return payload
}
func TestEmbyMediaSegmentsReturnsTicksAndMapsCreditsToOutro(t *testing.T) {
router, secret, _ := newEmbySegmentsTestRouter(t, embySegmentsMovieDurationSec)
payload := embySegmentsRequest(t, router, secret, "/MediaSegments/movie-1")
if payload.TotalRecordCount != 2 || len(payload.Items) != 2 {
t.Fatalf("segments = %#v (total %d), want 2", payload.Items, payload.TotalRecordCount)
}
intro := payload.Items[0]
if intro.Type != "Intro" || intro.ItemID != "movie-1" || intro.ID == "" {
t.Fatalf("intro segment = %#v", intro)
}
// start_ms null 表示从片头开始。
if intro.StartTicks != 0 || intro.EndTicks != 38*embySegmentsTicksPerSecond {
t.Fatalf("intro ticks = %d..%d, want 0..%d",
intro.StartTicks, intro.EndTicks, 38*embySegmentsTicksPerSecond)
}
// 库内 credits 在 Emby 一侧是 Outro;end_ms = 0 必须按媒体时长补齐,
// 否则客户端会拿到一个零长度区间。
outro := payload.Items[1]
if outro.Type != "Outro" {
t.Fatalf("credits should map to Outro, got %q", outro.Type)
}
if outro.StartTicks != 6480*embySegmentsTicksPerSecond {
t.Fatalf("outro StartTicks = %d, want %d", outro.StartTicks, 6480*embySegmentsTicksPerSecond)
}
if outro.EndTicks != embySegmentsMovieDurationSec*embySegmentsTicksPerSecond {
t.Fatalf("outro EndTicks = %d, want the media duration %d",
outro.EndTicks, embySegmentsMovieDurationSec*embySegmentsTicksPerSecond)
}
}
func TestEmbyMediaSegmentsIsServedFromTheSameCacheAsTheWebPlayer(t *testing.T) {
router, secret, calls := newEmbySegmentsTestRouter(t, embySegmentsMovieDurationSec)
// 多条路径 + 大小写变体都应命中同一份缓存,而不是各自再打一次外网。
for _, path := range []string{
"/MediaSegments/movie-1",
"/mediasegments/movie-1",
"/Items/movie-1/MediaSegments",
"/items/movie-1/mediasegments",
"/Users/user-1/Items/movie-1/MediaSegments",
} {
payload := embySegmentsRequest(t, router, secret, path)
if len(payload.Items) != 2 {
t.Fatalf("%s returned %#v, want 2 segments", path, payload.Items)
}
}
if got := atomic.LoadInt32(calls); got != 1 {
t.Fatalf("provider calls = %d, want 1 (all routes share the cached rows)", got)
}
}
func TestEmbyMediaSegmentsHonoursIncludeSegmentTypes(t *testing.T) {
router, secret, _ := newEmbySegmentsTestRouter(t, embySegmentsMovieDurationSec)
payload := embySegmentsRequest(t, router, secret, "/MediaSegments/movie-1?includeSegmentTypes=Intro")
if payload.TotalRecordCount != 1 || len(payload.Items) != 1 {
t.Fatalf("filtered segments = %#v (total %d), want only Intro", payload.Items, payload.TotalRecordCount)
}
if payload.Items[0].Type != "Intro" {
t.Fatalf("filtered type = %q, want Intro", payload.Items[0].Type)
}
// 认不出的枚举名按「不过滤」处理:返回超集比返回空集安全。
payload = embySegmentsRequest(t, router, secret, "/MediaSegments/movie-1?includeSegmentTypes=NotAType")
if payload.TotalRecordCount != 2 {
t.Fatalf("unknown filter returned %d segments, want the unfiltered set", payload.TotalRecordCount)
}
}
func TestEmbyMediaSegmentsDropsOpenEndedRangeWhenDurationUnknown(t *testing.T) {
// 时长未知(STRM/云盘媒体探测前)时,credits 无法换算成真实结束点,
// 只能丢弃;有明确结束点的 intro 必须保留。
router, secret, _ := newEmbySegmentsTestRouter(t, 0)
payload := embySegmentsRequest(t, router, secret, "/MediaSegments/movie-1")
if payload.TotalRecordCount != 1 || len(payload.Items) != 1 {
t.Fatalf("segments = %#v (total %d), want only the intro", payload.Items, payload.TotalRecordCount)
}
if payload.Items[0].Type != "Intro" {
t.Fatalf("kept segment = %#v, want Intro", payload.Items[0])
}
}
func TestEmbyMediaSegmentsReturnsEmptyInsteadOfNotFound(t *testing.T) {
router, secret, calls := newEmbySegmentsTestRouter(t, embySegmentsMovieDurationSec)
// 未知条目必须 200 + 空数组:客户端会把 404 判成「条目损坏」。
payload := embySegmentsRequest(t, router, secret, "/MediaSegments/does-not-exist")
if payload.Items == nil || len(payload.Items) != 0 || payload.TotalRecordCount != 0 {
t.Fatalf("unknown item payload = %#v", payload)
}
// 远程 Emby 条目同理(本地没有可查询的外部 ID 关联)。
payload = embySegmentsRequest(t, router, secret, "/MediaSegments/embyremote~acct1~item1")
if len(payload.Items) != 0 {
t.Fatalf("remote emby item payload = %#v, want empty", payload.Items)
}
if got := atomic.LoadInt32(calls); got != 0 {
t.Fatalf("provider calls = %d, want 0 for unresolvable items", got)
}
}
+93 -8
View File
@@ -1,10 +1,13 @@
package handler
import (
"context"
"errors"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
@@ -13,10 +16,7 @@ import (
func embyPlaybackInfoHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid := c.Param("userId")
if uid == "" {
uid = embyUserID(c)
}
uid := embyEffectiveUserID(c)
out, err := svc.Emby.PlaybackInfo(c.Request.Context(), c.Param("id"), uid)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -27,10 +27,98 @@ func embyPlaybackInfoHandler(svc *service.Container) gin.HandlerFunc {
return
}
embyAttachRequestTokenToMediaSources(c, out)
// 在后台把本次条目的云盘直链换好:播放器拿到 PlaybackInfo 后通常还要
// 1–2 秒才请求 /Videos/{id}/stream,把换链开销落在这段等待里。
embyPrewarmPlaybackTargets(svc, c, out)
c.JSON(http.StatusOK, out)
}
}
// embyPrewarmTimeout 是单次预热的等待上限。115 开放平台在跨太平洋线路上单次
// 换链实测 0.4–1.1s,这里给足余量;超时只是没预热成功,不影响后续播放。
const embyPrewarmTimeout = 10 * time.Second
// embyPrewarmInFlight 去重同一个条目的并发预热(首页刷新会并发请求多个接口,
// 同一条目可能在短时间内被多次请求)。
var embyPrewarmInFlight sync.Map
// embyPrewarmSlots 限制同时进行的预热数量。客户端可能批量预取 PlaybackInfo
// (逐个剧集的预取请求),预热只是优化,不能反过来把 115 换链接口打出突发。
// 名额满时直接跳过:排队等待的预热往往等真正播放时已经没意义了。
var embyPrewarmSlots = make(chan struct{}, 4)
// embyPrewarmPlaybackTargets 在后台预热本次 PlaybackInfo 涉及条目的云盘直链。
//
// 起播链路里最贵的一步是「服务端拿 pickcode 去 115 开放平台换直链」:服务器在
// 洛杉矶、115 接口在国内,冷启动实测 0.4–1.1s;之后 45 分钟内命中进程内缓存。
// 播放器在 PlaybackInfo 与真正拉流之间有几秒间隔,这里把换链放到那段间隔里,
// 起播时就只剩纯网络耗时。
//
// 只处理云盘/strm 条目,且失败一律静默忽略:预热是尽力而为的优化,不能影响
// PlaybackInfo 的正常返回。
func embyPrewarmPlaybackTargets(svc *service.Container, c *gin.Context, out map[string]any) {
if svc == nil || svc.Strm == nil || svc.Repo == nil || svc.Repo.Media == nil || out == nil {
return
}
ids := embyPrewarmMediaIDs(out)
if len(ids) == 0 {
return
}
userAgent := c.GetHeader("User-Agent")
// 预热是给「后续请求」用的:即便本次 PlaybackInfo 的连接断开,
// 也要把换链跑完。
base := context.WithoutCancel(c.Request.Context())
for _, mediaID := range ids {
if _, loaded := embyPrewarmInFlight.LoadOrStore(mediaID, struct{}{}); loaded {
continue
}
go func(id string) {
defer embyPrewarmInFlight.Delete(id)
select {
case embyPrewarmSlots <- struct{}{}:
defer func() { <-embyPrewarmSlots }()
default:
return
}
ctx, cancel := context.WithTimeout(base, embyPrewarmTimeout)
defer cancel()
m, err := svc.Repo.Media.FindByID(ctx, id)
if err != nil || m == nil {
return
}
raw := strings.TrimSpace(m.STRMURL)
if raw == "" || !service.IsStrmMediaRow(m) {
return
}
// 解析结果由 strm 层按 pickcode+UA 缓存;已缓存时这里是空转。
_, _ = svc.Strm.ResolvePlayTargetWithUA(ctx, raw, userAgent)
}(mediaID)
}
}
// embyPrewarmMediaIDs 取出 PlaybackInfo 载荷里 MediaSources 的条目 ID。
func embyPrewarmMediaIDs(out map[string]any) []string {
sources, ok := out["MediaSources"].([]map[string]any)
if !ok || len(sources) == 0 {
return nil
}
ids := make([]string, 0, len(sources))
seen := make(map[string]struct{}, len(sources))
for _, src := range sources {
id, _ := src["Id"].(string)
id = strings.TrimSpace(id)
if id == "" {
continue
}
if _, dup := seen[id]; dup {
continue
}
seen[id] = struct{}{}
ids = append(ids, id)
}
return ids
}
// embySubtitleStreamHandler serves an external subtitle track advertised in a
// MediaSource's MediaStreams via its Emby index
// (/Videos/:id/Subtitles/:index/Stream). The index maps to a discovered
@@ -47,10 +135,7 @@ func embySubtitleStreamHandler(svc *service.Container) gin.HandlerFunc {
}
return
}
uid := c.Param("userId")
if uid == "" {
uid = embyUserID(c)
}
uid := embyEffectiveUserID(c)
ctx := c.Request.Context()
// The official-format route carries a :format suffix (Stream.ass /
// Stream.vtt); prefer it for the Content-Type when present, otherwise
@@ -0,0 +1,159 @@
package handler
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
func newPrewarmTestContainer(t *testing.T) *service.Container {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(&model.Media{}, &model.Setting{}, &model.StrmAccount{}); err != nil {
t.Fatalf("migrate: %v", err)
}
if sqlDB, err := db.DB(); err == nil {
// 内存库 + 后台预热协程:限制单连接,避免新连接拿到空白的 :memory:。
sqlDB.SetMaxOpenConns(1)
}
repos := repository.New(db)
return &service.Container{
Log: zap.NewNop(),
Repo: repos,
Strm: service.NewStrmService(&config.Config{}, zap.NewNop(), repos, nil),
}
}
func newPrewarmTestContext() *gin.Context {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/emby/Items/media-1/PlaybackInfo", nil)
c.Request.Header.Set("User-Agent", "RodelPlayer/2.2607.7.0")
return c
}
func TestEmbyPrewarmMediaIDsExtractsDeduplicates(t *testing.T) {
out := map[string]any{
"MediaSources": []map[string]any{
{"Id": "src-1"},
{"Id": " src-1 "},
{"Id": "src-2"},
{"Id": ""},
{"Name": "no id"},
},
}
got := embyPrewarmMediaIDs(out)
if len(got) != 2 || got[0] != "src-1" || got[1] != "src-2" {
t.Fatalf("ids = %v, want [src-1 src-2]", got)
}
if got := embyPrewarmMediaIDs(map[string]any{}); len(got) != 0 {
t.Fatalf("missing MediaSources should yield no ids, got %v", got)
}
if got := embyPrewarmMediaIDs(map[string]any{"MediaSources": []any{}}); len(got) != 0 {
t.Fatalf("foreign payload shape should yield no ids, got %v", got)
}
}
// 预热是异步的:调用必须立即返回,并且协程结束后不能残留去重标记。
func TestEmbyPrewarmPlaybackTargetsRunsAsyncAndCleansUp(t *testing.T) {
svc := newPrewarmTestContainer(t)
if err := svc.Repo.DB.Create(&model.Media{
Base: model.Base{ID: "media-1"},
Title: "Cloud",
Path: "cloud://cloud115/Movie.mkv",
Container: "strm",
STRMURL: "/api/strm/play/cloud115/video.mkv?acct=missing&pickcode=pc1",
}).Error; err != nil {
t.Fatal(err)
}
embyPrewarmInFlight.Delete("media-1")
out := map[string]any{"MediaSources": []map[string]any{{"Id": "media-1"}}}
done := make(chan struct{})
go func() {
embyPrewarmPlaybackTargets(svc, newPrewarmTestContext(), out)
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("embyPrewarmPlaybackTargets blocked the caller")
}
// 后台协程应很快跑完并释放去重标记,否则同一条目后续再也预热不了。
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if _, busy := embyPrewarmInFlight.Load("media-1"); !busy {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("prewarm in-flight marker leaked")
}
// 各种缺数据的情况都不允许 panic 或阻塞:预热只是尽力而为的优化。
func TestEmbyPrewarmPlaybackTargetsIsNilSafe(t *testing.T) {
svc := newPrewarmTestContainer(t)
c := newPrewarmTestContext()
out := map[string]any{"MediaSources": []map[string]any{{"Id": "media-1"}}}
cases := []struct {
name string
svc *service.Container
out map[string]any
}{
{name: "空容器", svc: &service.Container{}, out: out},
{name: "无 Strm", svc: &service.Container{Repo: svc.Repo}, out: out},
{name: "无 Repo", svc: &service.Container{Strm: svc.Strm}, out: out},
{name: "nil 载荷", svc: svc, out: nil},
{name: "无 MediaSources", svc: svc, out: map[string]any{}},
{name: "条目不存在", svc: svc, out: out},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
embyPrewarmPlaybackTargets(tc.svc, c, tc.out)
})
}
}
// 本地文件条目不该触发换链预热(没有云盘直链可预热)。
func TestEmbyPrewarmSkipsLocalMedia(t *testing.T) {
svc := newPrewarmTestContainer(t)
if err := svc.Repo.DB.Create(&model.Media{
Base: model.Base{ID: "local-1"},
Title: "Local",
Path: "/media/movies/Local.mkv",
LibraryID: "lib-1",
}).Error; err != nil {
t.Fatal(err)
}
embyPrewarmInFlight.Delete("local-1")
embyPrewarmPlaybackTargets(svc, newPrewarmTestContext(),
map[string]any{"MediaSources": []map[string]any{{"Id": "local-1"}}})
// 协程要么已经跑完(标记被清掉),要么根本没起;两种都不该留下标记。
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if _, busy := embyPrewarmInFlight.Load("local-1"); !busy {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("local media must not leave a prewarm marker")
}
+2 -8
View File
@@ -92,10 +92,7 @@ func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
func embyFavoriteHandler(svc *service.Container, fav bool) gin.HandlerFunc {
return func(c *gin.Context) {
uid := c.Param("userId")
if uid == "" {
uid = embyUserID(c)
}
uid := embyEffectiveUserID(c)
mid := c.Param("itemId")
if uid == "" || mid == "" {
c.Status(http.StatusBadRequest)
@@ -116,10 +113,7 @@ func embyFavoriteHandler(svc *service.Container, fav bool) gin.HandlerFunc {
func embyMarkPlayedHandler(svc *service.Container, played bool) gin.HandlerFunc {
return func(c *gin.Context) {
uid := c.Param("userId")
if uid == "" {
uid = embyUserID(c)
}
uid := embyEffectiveUserID(c)
mid := c.Param("itemId")
if uid == "" || mid == "" {
c.Status(http.StatusBadRequest)
+10 -5
View File
@@ -196,15 +196,20 @@ func registerEmbyAuthenticatedItemRoutes(auth *gin.RouterGroup, svc *service.Con
auth.GET("/Shows/:id/Episodes", embyShowEpisodesHandler(svc))
auth.GET("/Users/:userId/Shows/:id/Seasons", embyShowSeasonsHandler(svc))
auth.GET("/Users/:userId/Shows/:id/Episodes", embyShowEpisodesHandler(svc))
auth.GET("/Shows/NextUp", embyEmptyItemsHandler(svc))
auth.GET("/Users/:userId/Shows/NextUp", embyEmptyItemsHandler(svc))
auth.GET("/MediaSegments/:id", embyEmptyItemsHandler(svc))
auth.GET("/Shows/NextUp", embyNextUpHandler(svc))
auth.GET("/Users/:userId/Shows/NextUp", embyNextUpHandler(svc))
// 部分客户端用路径形式 /Shows/{seriesId}/NextUp,而不是 query SeriesId。
auth.GET("/Shows/:id/NextUp", embyShowNextUpHandler(svc))
auth.GET("/Users/:userId/Shows/:id/NextUp", embyShowNextUpHandler(svc))
auth.GET("/MediaSegments/:id", embyMediaSegmentsHandler(svc))
auth.GET("/Items/:id/MediaSegments", embyMediaSegmentsHandler(svc))
auth.GET("/Users/:userId/Items/:id/MediaSegments", embyMediaSegmentsHandler(svc))
auth.GET("/Artists", embyEmptyItemsHandler(svc))
auth.GET("/Persons", embyEmptyItemsHandler(svc))
auth.GET("/Genres", embyEmptyItemsHandler(svc))
auth.GET("/Genres", embyGenresHandler(svc))
auth.GET("/Shows/Upcoming", embyEmptyItemsHandler(svc))
auth.GET("/Users/:userId/Shows/Upcoming", embyEmptyItemsHandler(svc))
auth.GET("/Items/:id/Similar", embyEmptyItemsHandler(svc))
auth.GET("/Items/:id/Similar", embySimilarHandler(svc))
auth.GET("/Items/:id/ThumbnailSet", embyEmptyItemsHandler(svc))
auth.GET("/Items/:id/ThemeMedia", embyThemeMediaHandler(svc))
auth.GET("/Users/:userId/Items/:id/SpecialFeatures", embyEmptyItemsHandler(svc))
+9 -5
View File
@@ -40,15 +40,19 @@ func registerLowercaseEmbyItemRoutes(auth *gin.RouterGroup, svc *service.Contain
auth.GET("/shows/:id/episodes", embyShowEpisodesHandler(svc))
auth.GET("/users/:userId/shows/:id/seasons", embyShowSeasonsHandler(svc))
auth.GET("/users/:userId/shows/:id/episodes", embyShowEpisodesHandler(svc))
auth.GET("/shows/nextup", embyEmptyItemsHandler(svc))
auth.GET("/users/:userId/shows/nextup", embyEmptyItemsHandler(svc))
auth.GET("/mediasegments/:id", embyEmptyItemsHandler(svc))
auth.GET("/shows/nextup", embyNextUpHandler(svc))
auth.GET("/users/:userId/shows/nextup", embyNextUpHandler(svc))
auth.GET("/shows/:id/nextup", embyShowNextUpHandler(svc))
auth.GET("/users/:userId/shows/:id/nextup", embyShowNextUpHandler(svc))
auth.GET("/mediasegments/:id", embyMediaSegmentsHandler(svc))
auth.GET("/items/:id/mediasegments", embyMediaSegmentsHandler(svc))
auth.GET("/users/:userId/items/:id/mediasegments", embyMediaSegmentsHandler(svc))
auth.GET("/artists", embyEmptyItemsHandler(svc))
auth.GET("/persons", embyEmptyItemsHandler(svc))
auth.GET("/genres", embyEmptyItemsHandler(svc))
auth.GET("/genres", embyGenresHandler(svc))
auth.GET("/shows/upcoming", embyEmptyItemsHandler(svc))
auth.GET("/users/:userId/shows/upcoming", embyEmptyItemsHandler(svc))
auth.GET("/items/:id/similar", embyEmptyItemsHandler(svc))
auth.GET("/items/:id/similar", embySimilarHandler(svc))
auth.GET("/items/:id/thumbnailset", embyEmptyItemsHandler(svc))
auth.GET("/items/:id/thememedia", embyThemeMediaHandler(svc))
auth.GET("/users/:userId/items/:id/specialfeatures", embyEmptyItemsHandler(svc))
@@ -0,0 +1,372 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
// embySeasonEpisodesRouter builds an Emby-compatible router over one series with
// two seasons, so /Shows/{id}/Episodes can be exercised with the query forms
// real clients send.
func embySeasonEpisodesRouter(t *testing.T) (*gin.Engine, string, string) {
t.Helper()
return embySeriesRouter(t, []model.Media{
{
Base: model.Base{ID: "s1e1"},
Title: "Test Show",
Path: "D:\\media\\tv\\Test Show\\Season 01\\Test Show - S01E01.mkv",
SeasonNum: 1,
EpisodeNum: 1,
Container: "mkv",
},
{
Base: model.Base{ID: "s2e1"},
Title: "Test Show",
Path: "D:\\media\\tv\\Test Show\\Season 02\\Test Show - S02E01.mkv",
SeasonNum: 2,
EpisodeNum: 1,
Container: "mkv",
},
})
}
// embySeriesRouter builds an Emby-compatible router with a single tv library and
// one series made of the given episode rows. Callers omit LibraryID: it is filled
// in here once the library exists.
func embySeriesRouter(t *testing.T, rows []model.Media) (*gin.Engine, string, string) {
t.Helper()
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(&model.User{}, &model.Library{}, &model.Series{}, &model.Media{}, &model.Favorite{}, &model.PlaybackHistory{}, &model.Setting{}); err != nil {
t.Fatalf("migrate: %v", err)
}
if sqlDB, err := db.DB(); err == nil {
sqlDB.SetMaxOpenConns(1)
}
repos := repository.New(db)
if err := repos.User.Create(t.Context(), &model.User{
Base: model.Base{ID: "user-1"},
Username: "tester",
PasswordHash: "x",
Role: "admin",
Tier: "plus",
IsActive: true,
}); err != nil {
t.Fatalf("create user: %v", err)
}
lib := model.Library{Name: "剧集", Path: "D:\\media\\tv", Type: "tv", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatalf("create library: %v", err)
}
for i := range rows {
rows[i].LibraryID = lib.ID
if err := db.Create(&rows[i]).Error; err != nil {
t.Fatalf("create media: %v", err)
}
}
const secret = "test-secret"
router := gin.New()
registerEmbyRoutes(router, secret, &service.Container{
Repo: repos,
Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos),
})
// Resolve the virtual series id the way a client would.
seriesReq := httptest.NewRequest(http.MethodGet, "/Items?ParentId="+lib.ID+"&IncludeItemTypes=Series", nil)
seriesReq.Header.Set("X-Emby-Token", signedTestToken(t, secret))
seriesRec := httptest.NewRecorder()
router.ServeHTTP(seriesRec, seriesReq)
if seriesRec.Code != http.StatusOK {
t.Fatalf("series lookup status=%d body=%s", seriesRec.Code, seriesRec.Body.String())
}
var seriesPayload struct {
Items []map[string]any `json:"Items"`
}
if err := json.Unmarshal(seriesRec.Body.Bytes(), &seriesPayload); err != nil {
t.Fatalf("decode series: %v", err)
}
if len(seriesPayload.Items) != 1 {
t.Fatalf("want one series, got %#v", seriesPayload.Items)
}
seriesID, _ := seriesPayload.Items[0]["Id"].(string)
if seriesID == "" {
t.Fatalf("series payload has no Id: %#v", seriesPayload.Items[0])
}
return router, secret, seriesID
}
func fetchSeasonID(t *testing.T, router *gin.Engine, secret, seriesID string, index int) string {
t.Helper()
path := "/Shows/" + seriesID + "/Seasons"
req := httptest.NewRequest(http.MethodGet, path, nil)
req.Header.Set("X-Emby-Token", signedTestToken(t, secret))
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("seasons status=%d body=%s", rec.Code, rec.Body.String())
}
var seasons struct {
Items []map[string]any `json:"Items"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &seasons); err != nil {
t.Fatalf("decode seasons: %v", err)
}
for _, s := range seasons.Items {
if got, ok := s["IndexNumber"].(float64); ok && int(got) == index {
if id, _ := s["Id"].(string); id != "" {
return id
}
}
}
t.Fatalf("season %d not found in %#v", index, seasons.Items)
return ""
}
func fetchEpisodeItems(t *testing.T, router *gin.Engine, secret, path string) ([]map[string]any, int) {
t.Helper()
req := httptest.NewRequest(http.MethodGet, path, nil)
req.Header.Set("X-Emby-Token", signedTestToken(t, secret))
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("%s status=%d body=%s", path, rec.Code, rec.Body.String())
}
var payload struct {
Items []map[string]any `json:"Items"`
TotalRecordCount float64 `json:"TotalRecordCount"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode %s: %v", path, err)
}
return payload.Items, int(payload.TotalRecordCount)
}
func fetchEpisodeIDs(t *testing.T, router *gin.Engine, secret, path string) ([]string, int) {
t.Helper()
items, total := fetchEpisodeItems(t, router, secret, path)
ids := make([]string, 0, len(items))
for _, item := range items {
id, _ := item["Id"].(string)
ids = append(ids, id)
}
return ids, total
}
// TestEmbyShowEpisodesHonoursSeasonQueryParam is the client-facing regression
// test: Emby clients scope episodes with ?Season=<index> (not only SeasonId), and
// the endpoint used to ignore it and return every season of the series.
func TestEmbyShowEpisodesHonoursSeasonQueryParam(t *testing.T) {
router, secret, seriesID := embySeasonEpisodesRouter(t)
cases := []struct {
name string
query string
wantIDs []string
}{
{name: "season 1", query: "Season=1", wantIDs: []string{"s1e1"}},
{name: "season 2", query: "Season=2", wantIDs: []string{"s2e1"}},
{name: "season index alias", query: "SeasonIndex=2", wantIDs: []string{"s2e1"}},
{name: "no season returns all", query: "", wantIDs: []string{"s1e1", "s2e1"}},
{name: "unknown season is empty", query: "Season=9", wantIDs: []string{}},
{name: "non numeric season falls back to all", query: "Season=Specials", wantIDs: []string{"s1e1", "s2e1"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
path := "/Shows/" + seriesID + "/Episodes"
if tc.query != "" {
path += "?" + tc.query
}
ids, total := fetchEpisodeIDs(t, router, secret, path)
if len(ids) != len(tc.wantIDs) {
t.Fatalf("episode ids = %#v (total=%d), want %#v", ids, total, tc.wantIDs)
}
for i := range tc.wantIDs {
if ids[i] != tc.wantIDs[i] {
t.Fatalf("episode ids = %#v, want %#v", ids, tc.wantIDs)
}
}
if total != len(tc.wantIDs) {
t.Fatalf("TotalRecordCount = %d, want %d", total, len(tc.wantIDs))
}
})
}
}
// TestEmbyShowEpisodesSeasonIdStillWins pins that the virtual SeasonId form keeps
// working, and that a conflicting Season query cannot empty it out.
func TestEmbyShowEpisodesSeasonIdStillWins(t *testing.T) {
router, secret, seriesID := embySeasonEpisodesRouter(t)
season1ID := fetchSeasonID(t, router, secret, seriesID, 1)
ids, _ := fetchEpisodeIDs(t, router, secret, "/Shows/"+seriesID+"/Episodes?SeasonId="+season1ID)
if len(ids) != 1 || ids[0] != "s1e1" {
t.Fatalf("SeasonId episodes = %#v, want [s1e1]", ids)
}
// A stale/conflicting season number must not override the explicit season id.
ids, _ = fetchEpisodeIDs(t, router, secret, "/Shows/"+seriesID+"/Episodes?SeasonId="+season1ID+"&Season=2")
if len(ids) != 1 || ids[0] != "s1e1" {
t.Fatalf("SeasonId+Season episodes = %#v, want [s1e1]", ids)
}
}
// foldedDuplicateSeriesRows models a season where two files resolve to the same
// episode number — 3月的狮子 S01E11 together with S01E11.5, which the episode
// parser reads as S01E11 and stores as a second row for season 1 episode 2 here.
func foldedDuplicateSeriesRows() []model.Media {
return []model.Media{
{
Base: model.Base{ID: "e1"},
Title: "三月的狮子",
Path: "D:\\media\\tv\\三月的狮子\\三月的狮子 - S01E01.mkv",
SeasonNum: 1,
EpisodeNum: 1,
Container: "mkv",
},
{
Base: model.Base{ID: "e2"},
Title: "三月的狮子",
Path: "D:\\media\\tv\\三月的狮子\\三月的狮子 - S01E02.mkv",
SeasonNum: 1,
EpisodeNum: 2,
Container: "mkv",
},
{
Base: model.Base{ID: "e2half"},
Title: "三月的狮子",
Path: "D:\\media\\tv\\三月的狮子\\三月的狮子 - S01E02.5.mkv",
SeasonNum: 1,
EpisodeNum: 2,
Container: "mkv",
},
}
}
// TestEmbyShowEpisodesFoldedDuplicateKeepsTotalsConsistent is the regression test
// for the "第一季分集加载不出来" lock-up: two rows sharing one season/episode are
// folded into a single Emby item with two MediaSources. TotalRecordCount used to
// be computed before that fold, so clients that page until they have
// TotalRecordCount items kept re-requesting the same page and never rendered the
// season.
func TestEmbyShowEpisodesFoldedDuplicateKeepsTotalsConsistent(t *testing.T) {
router, secret, seriesID := embySeriesRouter(t, foldedDuplicateSeriesRows())
seasonID := fetchSeasonID(t, router, secret, seriesID, 1)
items, total := fetchEpisodeItems(t, router, secret, "/Shows/"+seriesID+"/Episodes?SeasonId="+seasonID)
if total != len(items) {
t.Fatalf("TotalRecordCount = %d but %d items returned, want them equal", total, len(items))
}
if total != 2 {
t.Fatalf("TotalRecordCount = %d, want 2 distinct episodes", total)
}
// The folded episode must still carry both files so clients can switch version.
versioned := 0
for _, item := range items {
if sources, ok := item["MediaSources"].([]any); ok && len(sources) > 1 {
versioned++
}
}
if versioned != 1 {
t.Fatalf("items with multiple MediaSources = %d, want 1 (%#v)", versioned, items)
}
}
// TestEmbyShowEpisodesPagingAdvances pins that the client's StartIndex/Limit reach
// the episode list. Ignoring them made every page identical, so a client paging
// until it has TotalRecordCount items could never finish loading the season.
func TestEmbyShowEpisodesPagingAdvances(t *testing.T) {
router, secret, seriesID := embySeriesRouter(t, foldedDuplicateSeriesRows())
base := "/Shows/" + seriesID + "/Episodes?Season=1"
first, total := fetchEpisodeIDs(t, router, secret, base+"&StartIndex=0&Limit=1")
if total != 2 || len(first) != 1 {
t.Fatalf("page 1 = %#v (total=%d), want one item of two", first, total)
}
second, _ := fetchEpisodeIDs(t, router, secret, base+"&StartIndex=1&Limit=1")
if len(second) != 1 || second[0] == first[0] {
t.Fatalf("page 2 = %#v, want the remaining episode (page 1 = %#v)", second, first)
}
third, _ := fetchEpisodeIDs(t, router, secret, base+"&StartIndex=2&Limit=1")
if len(third) != 0 {
t.Fatalf("page 3 = %#v, want an empty page so clients stop paging", third)
}
}
// 半集(S01E11.5)在 Emby 侧必须和第 11 集并列返回:一旦被折成一个条目,
// TotalRecordCount 就会比 Items 多一条,按总数翻页的客户端会一直重发同一页
// (这就是「第一季分集加载不出来」的根因)。Emby 的 IndexNumber 只能是整数,
// 所以集号仍占 11,小数通过条目名体现。
func TestEmbyShowEpisodesKeepsHalfEpisodeVisible(t *testing.T) {
router, secret, seriesID := embySeriesRouter(t, []model.Media{
{
Base: model.Base{ID: "e11"},
Title: "三月的狮子",
Path: "D:\\media\\tv\\三月的狮子\\三月的狮子 - S01E11.mkv",
SeasonNum: 1,
EpisodeNum: 11,
Container: "mkv",
},
{
Base: model.Base{ID: "e11half"},
Title: "三月的狮子",
Path: "D:\\media\\tv\\三月的狮子\\三月的狮子 - S01E11.5.mkv",
SeasonNum: 1,
EpisodeNum: 11,
EpisodeFraction: 0.5,
Container: "mkv",
},
{
Base: model.Base{ID: "e12"},
Title: "三月的狮子",
Path: "D:\\media\\tv\\三月的狮子\\三月的狮子 - S01E12.mkv",
SeasonNum: 1,
EpisodeNum: 12,
Container: "mkv",
},
})
items, total := fetchEpisodeItems(t, router, secret, "/Shows/"+seriesID+"/Episodes?Season=1")
if total != 3 || len(items) != 3 {
t.Fatalf("TotalRecordCount = %d, items = %d, want 3/3", total, len(items))
}
wantOrder := []string{"e11", "e11half", "e12"}
for i, want := range wantOrder {
id, _ := items[i]["Id"].(string)
if id != want {
t.Fatalf("items[%d] = %q, want %q (order %#v)", i, id, want, items)
}
}
for _, item := range items {
sources, _ := item["MediaSources"].([]any)
if len(sources) != 1 {
t.Fatalf("item %v folded %d sources, want 1", item["Id"], len(sources))
}
}
halfName, _ := items[1]["Name"].(string)
if !strings.HasPrefix(halfName, "第 11.5 集") {
t.Fatalf("half episode name = %q, want prefix 第 11.5 集", halfName)
}
if idx, _ := items[1]["IndexNumber"].(float64); int(idx) != 11 {
t.Fatalf("half episode IndexNumber = %v, want 11 (Emby only supports integers)", items[1]["IndexNumber"])
}
}
+1 -4
View File
@@ -10,10 +10,7 @@ import (
func embyViewsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid := c.Param("userId")
if uid == "" {
uid = embyUserID(c)
}
uid := embyEffectiveUserID(c)
out, err := svc.Emby.Views(c.Request.Context(), uid)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+55
View File
@@ -0,0 +1,55 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/service"
)
// 媒体库筛选面板接口:facets 提供可选项,random 提供「随便看看」。
//
// 两者都走与列表完全相同的可见性判定(mediaVisibilityForRequest)与筛选解析
// (parseLibraryFilters),因此不会出现「列表里有、facets 里没有」或「随机跳
// 到了筛选条件之外的条目」这类不一致。
func libraryFacetsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
libraryID := c.Param("id")
facets, err := svc.Media.LibraryFacets(
c.Request.Context(),
libraryID,
mediaVisibilityForRequest(c, svc),
svc.Discovery,
)
if err != nil {
writeInternalOrCanceled(c, err)
return
}
c.JSON(http.StatusOK, facets)
}
}
func libraryRandomHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
libraryID := c.Param("id")
filters := parseLibraryFilters(c)
// 随机只取一条,因此不带分页参数;未观看筛选仍需要会话用户。
media, err := svc.Media.RandomMedia(
c.Request.Context(),
libraryID,
mediaVisibilityForRequest(c, svc),
filters,
)
if err != nil {
writeInternalOrCanceled(c, err)
return
}
if media == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "no media matches the current filters"})
return
}
c.JSON(http.StatusOK, media)
}
}
+283
View File
@@ -0,0 +1,283 @@
package handler
import (
"context"
"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/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
func newLibraryFilterEnv(t *testing.T) (*gin.Engine, *service.Container, string, string) {
t.Helper()
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(
&model.User{}, &model.Library{}, &model.Media{}, &model.PlaybackHistory{}, &model.Setting{},
); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
svc := &service.Container{Repo: repos, Log: zap.NewNop()}
svc.Media = service.NewMediaService(nil, zap.NewNop(), repos)
svc.Discovery = service.NewMediaDiscoveryService(zap.NewNop(), repos)
const userID = "user-1"
if err := repos.User.Create(context.Background(), &model.User{
Base: model.Base{ID: userID}, Username: "tester", PasswordHash: "x", Role: "user", IsActive: true,
}); err != nil {
t.Fatal(err)
}
lib := &model.Library{Name: "电影", Path: "/media/movies", Type: "movie", Enabled: true}
if err := repos.Library.Create(context.Background(), lib); err != nil {
t.Fatal(err)
}
router := gin.New()
authed := router.Group("/api", func(c *gin.Context) {
c.Set(middleware.CtxUserID, userID)
c.Set(middleware.CtxUserRole, "user")
c.Next()
})
authed.GET("/libraries/:id/media", listMediaHandler(svc))
authed.GET("/libraries/:id/facets", libraryFacetsHandler(svc))
authed.GET("/libraries/:id/random", libraryRandomHandler(svc))
return router, svc, userID, lib.ID
}
func seedLibraryMedia(t *testing.T, svc *service.Container, rows ...*model.Media) {
t.Helper()
for _, row := range rows {
if err := svc.Repo.DB.Create(row).Error; err != nil {
t.Fatal(err)
}
}
}
func getJSON(t *testing.T, router *gin.Engine, path string) (int, []byte) {
t.Helper()
req := httptest.NewRequest(http.MethodGet, path, nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w.Code, w.Body.Bytes()
}
func TestLibraryFacetsReturnGenresAndYears(t *testing.T) {
router, svc, _, libID := newLibraryFilterEnv(t)
seedLibraryMedia(t, svc,
&model.Media{LibraryID: libID, Title: "A", Genres: "Action,Drama", Year: 1999, Path: "/a.mkv"},
&model.Media{LibraryID: libID, Title: "B", Genres: "Action", Year: 2021, Path: "/b.mkv"},
)
code, body := getJSON(t, router, "/api/libraries/"+libID+"/facets")
if code != http.StatusOK {
t.Fatalf("status = %d body=%s", code, body)
}
var facets struct {
Genres []struct {
Name string `json:"name"`
Count int `json:"count"`
} `json:"genres"`
YearMin int `json:"year_min"`
YearMax int `json:"year_max"`
}
if err := json.Unmarshal(body, &facets); err != nil {
t.Fatalf("decode %s: %v", body, err)
}
if facets.YearMin != 1999 || facets.YearMax != 2021 {
t.Fatalf("year range = %d..%d, want 1999..2021", facets.YearMin, facets.YearMax)
}
if len(facets.Genres) != 2 {
t.Fatalf("genres = %+v, want 2 entries", facets.Genres)
}
if facets.Genres[0].Name != "Action" || facets.Genres[0].Count != 2 {
t.Fatalf("first genre = %+v, want Action:2", facets.Genres[0])
}
}
// 空库时 facets 必须返回空数组而不是 null,前端无需额外判空。
func TestLibraryFacetsEmptyLibrary(t *testing.T) {
router, _, _, libID := newLibraryFilterEnv(t)
code, body := getJSON(t, router, "/api/libraries/"+libID+"/facets")
if code != http.StatusOK {
t.Fatalf("status = %d body=%s", code, body)
}
if !containsSubstring(string(body), `"genres":[]`) {
t.Fatalf("body = %s, want genres:[]", body)
}
}
// 列表筛选:按类型过滤后只返回命中的条目。
func TestListMediaAppliesGenreFilter(t *testing.T) {
router, svc, _, libID := newLibraryFilterEnv(t)
seedLibraryMedia(t, svc,
&model.Media{LibraryID: libID, Title: "动作", Genres: "Action", Path: "/a.mkv"},
&model.Media{LibraryID: libID, Title: "喜剧", Genres: "Comedy", Path: "/b.mkv"},
)
code, body := getJSON(t, router, "/api/libraries/"+libID+"/media?group_versions=0&genre=Action")
if code != http.StatusOK {
t.Fatalf("status = %d body=%s", code, body)
}
var payload struct {
Items []model.Media `json:"items"`
Total int64 `json:"total"`
}
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatalf("decode %s: %v", body, err)
}
if payload.Total != 1 || len(payload.Items) != 1 || payload.Items[0].Title != "动作" {
t.Fatalf("payload = %+v, want only 动作", payload)
}
}
// 筛选条件必须进入缓存键:先请求未筛选列表、再筛选时不能命中旧缓存。
func TestListMediaFilterBypassesUnfilteredCache(t *testing.T) {
router, svc, _, libID := newLibraryFilterEnv(t)
seedLibraryMedia(t, svc,
&model.Media{LibraryID: libID, Title: "动作", Genres: "Action", Path: "/a.mkv"},
&model.Media{LibraryID: libID, Title: "喜剧", Genres: "Comedy", Path: "/b.mkv"},
)
// 先拉全量(可能写缓存),再拉筛选结果。
if code, body := getJSON(t, router, "/api/libraries/"+libID+"/media?group_versions=0"); code != http.StatusOK {
t.Fatalf("unfiltered status = %d body=%s", code, body)
}
code, body := getJSON(t, router, "/api/libraries/"+libID+"/media?group_versions=0&genre=Comedy")
if code != http.StatusOK {
t.Fatalf("filtered status = %d body=%s", code, body)
}
var payload struct {
Total int64 `json:"total"`
}
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatal(err)
}
if payload.Total != 1 {
t.Fatalf("total = %d, want 1 (filtered response must not be served from the unfiltered cache)", payload.Total)
}
}
// 未观看筛选:已看完的不出现,看了一半的仍出现。
func TestListMediaUnwatchedFilter(t *testing.T) {
router, svc, userID, libID := newLibraryFilterEnv(t)
seedLibraryMedia(t, svc,
&model.Media{Base: model.Base{ID: "m-done"}, LibraryID: libID, Title: "看完", Path: "/a.mkv"},
&model.Media{Base: model.Base{ID: "m-half"}, LibraryID: libID, Title: "看一半", Path: "/b.mkv"},
)
for _, h := range []*model.PlaybackHistory{
{UserID: userID, MediaID: "m-done", Completed: true},
{UserID: userID, MediaID: "m-half", Completed: false},
} {
if err := svc.Repo.DB.Create(h).Error; err != nil {
t.Fatal(err)
}
}
code, body := getJSON(t, router, "/api/libraries/"+libID+"/media?group_versions=0&unwatched=1")
if code != http.StatusOK {
t.Fatalf("status = %d body=%s", code, body)
}
var payload struct {
Items []model.Media `json:"items"`
}
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatal(err)
}
if len(payload.Items) != 1 || payload.Items[0].Title != "看一半" {
t.Fatalf("items = %+v, want only 看一半", payload.Items)
}
}
func TestLibraryRandomReturnsMedia(t *testing.T) {
router, svc, _, libID := newLibraryFilterEnv(t)
seedLibraryMedia(t, svc,
&model.Media{LibraryID: libID, Title: "唯一", Genres: "Action", Path: "/a.mkv"},
)
code, body := getJSON(t, router, "/api/libraries/"+libID+"/random")
if code != http.StatusOK {
t.Fatalf("status = %d body=%s", code, body)
}
var media model.Media
if err := json.Unmarshal(body, &media); err != nil {
t.Fatalf("decode %s: %v", body, err)
}
if media.Title != "唯一" {
t.Fatalf("title = %q, want 唯一", media.Title)
}
}
// 筛选后没有命中时返回 404,前端据此提示「没有符合条件的媒体」。
func TestLibraryRandomEmptyResultIs404(t *testing.T) {
router, svc, _, libID := newLibraryFilterEnv(t)
seedLibraryMedia(t, svc,
&model.Media{LibraryID: libID, Title: "动作", Genres: "Action", Path: "/a.mkv"},
)
code, body := getJSON(t, router, "/api/libraries/"+libID+"/random?genre=Nonexistent")
if code != http.StatusNotFound {
t.Fatalf("status = %d body=%s, want 404", code, body)
}
}
// axios 默认把数组序列化为 genre[]=Action 格式;后端必须把它当作 genre=Action 处理。
func TestListMediaAcceptsBracketGenreParam(t *testing.T) {
router, svc, _, libID := newLibraryFilterEnv(t)
seedLibraryMedia(t, svc,
&model.Media{LibraryID: libID, Title: "动作", Genres: "Action", Path: "/a.mkv"},
&model.Media{LibraryID: libID, Title: "喜剧", Genres: "Comedy", Path: "/b.mkv"},
)
// genre[]=Action — axios bracket format without custom paramsSerializer
code, body := getJSON(t, router, "/api/libraries/"+libID+"/media?group_versions=0&genre[]=Action")
if code != http.StatusOK {
t.Fatalf("status = %d body=%s", code, body)
}
var payload struct {
Items []model.Media `json:"items"`
Total int64 `json:"total"`
}
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatalf("decode %s: %v", body, err)
}
if payload.Total != 1 || len(payload.Items) != 1 || payload.Items[0].Title != "动作" {
t.Fatalf("payload = %+v, want only 动作 for genre[]=Action", payload)
}
}
// 随机也遵守筛选:只命中 Action 时,带 Comedy 筛选必须 404。
func TestLibraryRandomHonoursFilters(t *testing.T) {
router, svc, _, libID := newLibraryFilterEnv(t)
seedLibraryMedia(t, svc,
&model.Media{LibraryID: libID, Title: "A", Genres: "Action", Year: 2001, Path: "/a.mkv"},
&model.Media{LibraryID: libID, Title: "B", Genres: "Comedy", Year: 2002, Path: "/b.mkv"},
)
code, body := getJSON(t, router, "/api/libraries/"+libID+"/random?genre=Comedy&year_min=2002")
if code != http.StatusOK {
t.Fatalf("status = %d body=%s", code, body)
}
var media model.Media
if err := json.Unmarshal(body, &media); err != nil {
t.Fatal(err)
}
if media.Title != "B" {
t.Fatalf("title = %q, want B", media.Title)
}
}
+102 -3
View File
@@ -4,6 +4,7 @@ package handler
import (
"context"
"errors"
"math"
"net/http"
"strconv"
"strings"
@@ -407,6 +408,92 @@ func deleteLibraryHandler(svc *service.Container) gin.HandlerFunc {
}
}
// parseLibraryFilters 解析媒体库列表的筛选查询参数。
//
// 全部参数都是可选的:缺省时返回零值,`MediaListFilters.empty()` 为真,列表
// 行为与此前完全一致(不引入任何默认筛选)。
//
// 参数约定:
// - genre=Action&genre=Comedy 类型多选(或关系,整词匹配)
// - year_min / year_max 年份区间,0 或非法值表示不限
// - rating_min 评分下限(浮点)
// - unwatched=1 仅显示未看完;用户 ID 取自会话
func parseLibraryFilters(c *gin.Context) service.MediaListFilters {
filters := service.MediaListFilters{
Genres: parseRepeatedQueryValues(c, "genre"),
YearMin: parseNonNegativeInt(firstQueryValue(c, "year_min", "yearMin")),
YearMax: parseNonNegativeInt(firstQueryValue(c, "year_max", "yearMax")),
RatingMin: parseNonNegativeFloat(firstQueryValue(c, "rating_min", "ratingMin")),
}
if isTruthyQuery(firstQueryValue(c, "unwatched", "unwatched_only", "unwatchedOnly")) {
filters.Unwatched = true
filters.UserID = toString(mustSessionUserID(c))
}
return filters
}
// parseRepeatedQueryValues 读取可重复出现的查询参数,去重并丢弃空值。
// 同时接受 key[] 括号格式(axios 1.x 默认序列化方式)作为向后兼容回退,
// 在前端 paramsSerializer 未正确配置时不会静默返回空结果。
func parseRepeatedQueryValues(c *gin.Context, key string) []string {
raw := c.QueryArray(key)
if len(raw) == 0 {
// fallback: axios bracket format (e.g. genre[]=Action&genre[]=Comedy)
raw = c.QueryArray(key + "[]")
}
if len(raw) == 0 {
return nil
}
seen := make(map[string]struct{}, len(raw))
out := make([]string, 0, len(raw))
for _, value := range raw {
// 客户端可能把多值拼成一次逗号分隔,两种形式都要接受。
for _, part := range strings.Split(value, ",") {
trimmed := strings.TrimSpace(part)
if trimmed == "" {
continue
}
if _, ok := seen[trimmed]; ok {
continue
}
seen[trimmed] = struct{}{}
out = append(out, trimmed)
}
}
return out
}
func parseNonNegativeInt(raw string) int {
value, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil || value < 0 {
return 0
}
return value
}
func parseNonNegativeFloat(raw string) float64 {
value, err := strconv.ParseFloat(strings.TrimSpace(raw), 64)
if err != nil || value < 0 || math.IsNaN(value) {
return 0
}
return value
}
func isTruthyQuery(raw string) bool {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
// mustSessionUserID 取会话用户 ID,缺失时返回空串(筛选逻辑会忽略它)。
func mustSessionUserID(c *gin.Context) any {
uid, _ := c.Get(middleware.CtxUserID)
return uid
}
func listMediaHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
id := c.Param("id")
@@ -450,9 +537,10 @@ func listMediaHandler(svc *service.Container) gin.HandlerFunc {
if sortSpec.Field == "last_played" {
history = mediaHistoryMap(c, svc)
}
filters := parseLibraryFilters(c)
groupVersions := c.DefaultQuery("group_versions", "1") != "0"
if !groupVersions {
items, total, err := svc.Media.ListMediaVisible(ctx, id, page, size, mediaVisibilityForRequest(c, svc))
items, total, err := svc.Media.ListMediaVisibleFiltered(ctx, id, page, size, mediaVisibilityForRequest(c, svc), filters)
if err != nil {
writeInternalOrCanceled(c, err)
return
@@ -468,7 +556,7 @@ func listMediaHandler(svc *service.Container) gin.HandlerFunc {
})
return
}
grouped, err := svc.Media.GroupedMediaVisible(ctx, id, mediaVisibilityForRequest(c, svc))
grouped, err := svc.Media.GroupedMediaVisibleFiltered(ctx, id, mediaVisibilityForRequest(c, svc), filters)
if err != nil {
writeInternalOrCanceled(c, err)
return
@@ -722,7 +810,9 @@ func streamHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if mount.ProxyPlay {
// 远程 Emby 条目的同源转发:VR 全景需要浏览器读帧(见 stream_proxy.go),
// 与挂载账号的 proxy_play 开关等价。
if mount.ProxyPlay || wantSameOriginProxy(c) {
if err := svc.Emby.ProxyRemoteVideoStream(ctx, c.Writer, c.Request, mountID, remoteID); err != nil {
if !c.Writer.Written() {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
@@ -758,6 +848,15 @@ func streamHandler(svc *service.Container) gin.HandlerFunc {
if !enforceScopedPlaybackToken(c, m.ID) {
return
}
// ?proxy=1:把网盘/STRM 直链改为服务端同源转发(网页端读帧、VR 全景用),
// 画质与原文件一致,不触发转码。
if wantSameOriginProxy(c) {
handled, proxyErr := proxySTRMStream(c, svc, m)
if handled {
writeProxyError(c, proxyErr)
return
}
}
err = svc.Stream.ServeFile(c.Writer, c.Request, c.Param("id"))
if errors.Is(err, service.ErrMediaNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
+40
View File
@@ -6,6 +6,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service"
)
@@ -13,6 +14,45 @@ type pinnedLibrariesReq struct {
LibraryIDs []string `json:"library_ids"`
}
type libraryTagsReq struct {
Tags []model.LibraryTagSet `json:"tags"`
}
func getLibraryTagsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid, _ := c.Get(middleware.CtxUserID)
tags, err := svc.Profile.GetLibraryTags(c.Request.Context(), uid.(string))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if tags == nil {
tags = []model.LibraryTagSet{}
}
c.JSON(http.StatusOK, gin.H{"tags": tags})
}
}
func setLibraryTagsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req libraryTagsReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
uid, _ := c.Get(middleware.CtxUserID)
tags, err := svc.Profile.SetLibraryTags(c.Request.Context(), uid.(string), req.Tags)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if tags == nil {
tags = []model.LibraryTagSet{}
}
c.JSON(http.StatusOK, gin.H{"tags": tags})
}
}
func getPinnedLibrariesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid, _ := c.Get(middleware.CtxUserID)
+4
View File
@@ -346,6 +346,9 @@ func newPlaybackScopeTestRouter(t *testing.T) (*gin.Engine, *service.Container,
&model.Library{},
&model.Media{},
&model.PlayProfile{},
&model.Series{},
&model.MediaSegment{},
&model.MediaSegmentFetch{},
); err != nil {
t.Fatal(err)
}
@@ -408,6 +411,7 @@ func newPlaybackScopeTestRouter(t *testing.T) (*gin.Engine, *service.Container,
api := router.Group("/api")
api.Use(middleware.AuthRequired(cfg.Secrets.JWTSecret))
api.GET("/playback/:id/info", playbackInfoHandler(svc))
api.GET("/playback/:id/segments", playbackSegmentsHandler(svc))
api.GET("/playback/:id/external-url", externalURLHandler(svc))
api.GET("/playback/:id/external-players", externalPlayersHandler(svc))
api.GET("/stream/:id", streamHandler(svc))
+55
View File
@@ -0,0 +1,55 @@
// Package handler — 片头/片尾片段接口。
//
// GET /playback/:id/segments
//
// 单独开一个接口而不是塞进 /media/:id/playback,有两个原因:一是抓取外部数据
// 可能要几秒,不能拖慢决定能否起播的那个请求;二是片段与播放来源(本地 / 云盘 /
// 远程 Emby)无关,独立出来对所有媒体一致。
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/service"
)
// playbackSegmentsHandler returns the skippable ranges for one media item.
// The client calls it after playback has already started, so the provider
// lookup never delays a play.
func playbackSegmentsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
autoSkip := resolveAutoSkipFlag(c, svc)
segments := []service.SegmentView{}
m, err := findMediaForPlaybackEndpoint(c, svc, c.Param("id"))
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
c.JSON(http.StatusNotFound, gin.H{"error": "media not found"})
return
}
// 远程 Emby 挂载的条目是上游库的投影,本地没有可查询的外部 ID 关联。
if svc.Segments != nil && !service.IsEmbyRemoteID(m.ID) {
rows, listErr := svc.Segments.ListForPlayback(c.Request.Context(), m)
if listErr != nil && svc.Log != nil {
svc.Log.Debug("list media segments failed",
zap.String("media_id", m.ID), zap.Error(listErr))
}
segments = service.ToSegmentViews(rows)
}
c.JSON(http.StatusOK, gin.H{"segments": segments, "auto_skip": autoSkip})
}
}
// resolveAutoSkipFlag reads the「自动跳过片头」switch off whichever profile is
// currently in effect. It reuses selectedPlayProfile so the server agrees with
// the UI about which profile is active (explicit header first, then the user's
// default profile), and a PIN-locked profile never silently skips for the user.
func resolveAutoSkipFlag(c *gin.Context, svc *service.Container) bool {
profile, locked := selectedPlayProfile(c, svc)
if locked || profile == nil {
return false
}
return profile.SkipIntro
}
+134
View File
@@ -0,0 +1,134 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service"
)
type segmentPayload struct {
Segments []struct {
Kind string `json:"kind"`
StartMs int64 `json:"start_ms"`
EndMs int64 `json:"end_ms"`
} `json:"segments"`
AutoSkip bool `json:"auto_skip"`
}
const segmentsProviderBody = `{"tmdb_id":27205,"type":"movie","intro":[{"start_ms":null,"end_ms":38000}],"credits":[{"start_ms":6480000,"end_ms":null}]}`
func TestPlaybackSegmentsReturnsProviderDataAndAutoSkip(t *testing.T) {
router, svc, secret := newPlaybackScopeTestRouter(t)
// 片段数据与播放来源无关,云盘媒体同样适用,只要它能解析出外部 ID。
// 注意列名是 tm_db_id(GORM 对 TMDbID 的默认命名)。
if err := svc.Repo.DB.Model(&model.Media{}).
Where("id = ?", "media-1").Update("tm_db_id", 27205).Error; err != nil {
t.Fatal(err)
}
var calls int32
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
atomic.AddInt32(&calls, 1)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(segmentsProviderBody))
}))
defer provider.Close()
svc.Segments = service.NewMediaSegmentService(zap.NewNop(), svc.Repo).
SetIntroDB(service.NewIntroDBService(zap.NewNop()).SetBaseURL(provider.URL))
// 默认档案打开「自动跳过片头」,接口应把开关原样带出来。
if err := svc.Repo.DB.Create(&model.PlayProfile{
Base: model.Base{ID: "profile-1"},
UserID: "user-1",
Name: "主档案",
IsDefault: true,
SkipIntro: true,
}).Error; err != nil {
t.Fatal(err)
}
loginToken := signedTestToken(t, secret)
fetch := func() segmentPayload {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "http://nas.local/api/playback/media-1/segments", 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 segmentPayload
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode: %v", err)
}
return payload
}
first := fetch()
if !first.AutoSkip {
t.Fatal("auto_skip should reflect the active profile's skip_intro switch")
}
if len(first.Segments) != 2 {
t.Fatalf("segments = %#v, want 2", first.Segments)
}
// start_ms: null -> 0;end_ms: null -> 0(延续到片尾,由客户端按时长补齐)。
if first.Segments[0].Kind != "intro" || first.Segments[0].StartMs != 0 || first.Segments[0].EndMs != 38_000 {
t.Fatalf("intro segment = %#v", first.Segments[0])
}
if first.Segments[1].Kind != "credits" || first.Segments[1].StartMs != 6_480_000 || first.Segments[1].EndMs != 0 {
t.Fatalf("credits segment = %#v", first.Segments[1])
}
// 第二次播放必须走本地缓存,不再打外网。
if second := fetch(); len(second.Segments) != 2 {
t.Fatalf("second fetch segments = %#v", second.Segments)
}
if got := atomic.LoadInt32(&calls); got != 1 {
t.Fatalf("provider calls = %d, want 1", got)
}
}
func TestPlaybackSegmentsAutoSkipIsFalseWithoutProfile(t *testing.T) {
router, svc, secret := newPlaybackScopeTestRouter(t)
svc.Segments = service.NewMediaSegmentService(zap.NewNop(), svc.Repo)
req := httptest.NewRequest(http.MethodGet, "http://nas.local/api/playback/media-1/segments", nil)
req.Header.Set("Authorization", "Bearer "+signedTestToken(t, secret))
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 segmentPayload
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode: %v", err)
}
if payload.AutoSkip {
t.Fatal("auto_skip must default to false")
}
// 即使一条片段都没有,也必须返回空数组而不是 null,前端才能无条件遍历。
if payload.Segments == nil {
t.Fatal("segments must serialise as an empty array, not null")
}
}
func TestPlaybackSegmentsForUnknownMediaReturnsNotFound(t *testing.T) {
router, svc, secret := newPlaybackScopeTestRouter(t)
svc.Segments = service.NewMediaSegmentService(zap.NewNop(), svc.Repo)
req := httptest.NewRequest(http.MethodGet, "http://nas.local/api/playback/does-not-exist/segments", nil)
req.Header.Set("Authorization", "Bearer "+signedTestToken(t, secret))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("status = %d body=%s, want 404", w.Code, w.Body.String())
}
}
+5
View File
@@ -116,8 +116,13 @@ func registerAdminUserRoutes(admin *gin.RouterGroup, svc *service.Container) {
admin.PATCH("/users/:id/role", adminUpdateRoleHandler(svc))
admin.PATCH("/users/:id/libraries", updateUserLibrariesHandler(svc))
admin.DELETE("/users/:id", deleteUserHandler(svc))
// 设备代管:管理员可查看并踢掉任意用户的设备。
admin.GET("/users/:id/devices", adminUserDevicesHandler(svc))
admin.POST("/users/:id/devices/kick-all", adminKickAllUserDevicesHandler(svc))
admin.POST("/users/:id/devices/:deviceID/kick", adminKickUserDeviceHandler(svc))
admin.GET("/settings", listSettingsHandler(svc))
admin.PUT("/settings", updateSettingHandler(svc))
admin.POST("/telegram/test", testTelegramHandler(svc))
admin.POST("/adult/test-scraper", testAdultScraperHandler(svc))
admin.GET("/logs", recentLogsHandler(svc))
}
@@ -12,11 +12,23 @@ func registerAuthedUserAndLicenseRoutes(authed *gin.RouterGroup, svc *service.Co
authed.PATCH("/me", updateProfileHandler(svc))
authed.GET("/me/pinned-libraries", getPinnedLibrariesHandler(svc))
authed.PUT("/me/pinned-libraries", setPinnedLibrariesHandler(svc))
authed.GET("/me/library-tags", getLibraryTagsHandler(svc))
authed.PUT("/me/library-tags", setLibraryTagsHandler(svc))
authed.POST("/me/password", changePasswordHandler(svc))
authed.POST("/me/logout", logoutHandler(svc))
authed.GET("/me/temporary-password", temporaryPasswordHandler(svc))
authed.POST("/me/temporary-password", temporaryPasswordHandler(svc))
// 设备管理:路由挂在 /me 下,用户 ID 一律取自会话,天然只能管自己的设备。
authed.GET("/me/devices", myDevicesHandler(svc))
authed.POST("/me/devices/kick-all", myKickAllDevicesHandler(svc))
authed.POST("/me/devices/:deviceID/kick", myKickDeviceHandler(svc))
// Telegram 通知绑定:一次性码 + Bot /bind <code>。
authed.GET("/me/telegram", getTelegramStatusHandler(svc))
authed.POST("/me/telegram/bind-code", startTelegramBindHandler(svc))
authed.DELETE("/me/telegram", unbindTelegramHandler(svc))
authed.GET("/auth/permissions", getMyPermissionsHandler(svc))
}
@@ -36,6 +48,8 @@ func registerAuthedLibraryRoutes(authed *gin.RouterGroup, svc *service.Container
authed.POST("/libraries/:id/scrape", middleware.AdminRequired(), scrapeLibraryHandler(svc))
authed.GET("/libraries/:id/media", listMediaHandler(svc))
authed.GET("/libraries/:id/facets", libraryFacetsHandler(svc))
authed.GET("/libraries/:id/random", libraryRandomHandler(svc))
authed.GET("/libraries/:id/series", listLibrarySeriesHandler(svc))
authed.GET("/libraries/:id/series/episodes", listLibrarySeriesEpisodesHandler(svc))
authed.GET("/libraries/:id/seasons", listSeasonsHandler(svc))
@@ -15,7 +15,7 @@ func registerAuthedUISurfaceRoutes(authed *gin.RouterGroup, svc *service.Contain
authed.PUT("/danmaku/settings", updateDanmakuSettingsHandler(svc))
authed.GET("/watch-history", historyListHandler(svc))
authed.GET("/watch-history/stats", historyStatsHandler(svc))
authed.GET("/watch-history/stats", requirePermission(svc, "can_view_history"), historyStatsHandler(svc))
authed.GET("/watch-history/continue", historyContinueHandler(svc))
authed.DELETE("/watch-history", historyDeleteHandler(svc))
authed.DELETE("/watch-history/:id", historyDeleteOneHandler(svc))
@@ -73,6 +73,7 @@ func registerAuthedFavoriteAndMediaActionRoutes(authed *gin.RouterGroup, svc *se
func registerAuthedPlaybackExtraRoutes(authed *gin.RouterGroup, svc *service.Container) {
authed.GET("/playback/:id/info", playbackInfoHandler(svc))
authed.GET("/playback/:id/resume", playbackResumeHandler(svc))
authed.GET("/playback/:id/segments", playbackSegmentsHandler(svc))
authed.POST("/playback/:id/progress", playbackProgressHandler(svc))
authed.GET("/playback/:id/external-players", externalPlayersHandler(svc))
authed.GET("/playback/:id/external-url", externalURLHandler(svc))
@@ -27,6 +27,8 @@ func TestAuthenticatedRouteSurfacesAreRegistered(t *testing.T) {
"GET /api/me",
"GET /api/me/pinned-libraries",
"PUT /api/me/pinned-libraries",
"GET /api/me/library-tags",
"PUT /api/me/library-tags",
"GET /api/auth/permissions",
"GET /api/libraries",
"GET /api/media",
@@ -34,6 +36,7 @@ func TestAuthenticatedRouteSurfacesAreRegistered(t *testing.T) {
"GET /api/storage",
"GET /api/watch-history",
"GET /api/playback/:id/info",
"GET /api/playback/:id/segments",
} {
if !routes[want] {
t.Fatalf("%s route is not registered", want)
+4 -2
View File
@@ -89,7 +89,7 @@ func listLibrarySeriesHandler(svc *service.Container) gin.HandlerFunc {
writeInternalOrCanceled(c, err)
return
}
cards = service.SortSeriesCards(cards, sortSpec.Field, sortSpec.Order, history)
cards = sortRemoteSeriesCards(cards, sortSpec, history)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "500"))
if page < 1 {
@@ -124,7 +124,9 @@ func listLibrarySeriesHandler(svc *service.Container) gin.HandlerFunc {
return
}
}
items, total, err := svc.Media.ListLibrarySeriesCards(c.Request.Context(), libID, mediaVisibilityForRequest(c, svc))
items, total, err := svc.Media.ListLibrarySeriesCardsFiltered(
c.Request.Context(), libID, mediaVisibilityForRequest(c, svc), parseLibraryFilters(c),
)
if err != nil {
writeInternalOrCanceled(c, err)
return
+23
View File
@@ -0,0 +1,23 @@
package handler
import (
"slices"
"time"
"github.com/truewhile/MeBox/internal/service"
)
// sortRemoteSeriesCards keeps the DateLastContentAdded order returned by the
// remote Emby server when LastAddedAt is not exposed in the Series DTO.
func sortRemoteSeriesCards(cards []service.SeriesCard, spec service.MediaSortSpec, history map[string]time.Time) []service.SeriesCard {
if spec.Field != "updated_at" {
return service.SortSeriesCards(cards, spec.Field, spec.Order, history)
}
out := append([]service.SeriesCard(nil), cards...)
// RemoteSeriesCards always fetches DateLastContentAdded in descending order.
if spec.Order == "asc" {
slices.Reverse(out)
}
return service.SortSeriesCards(out, spec.Field, spec.Order, history)
}
+21
View File
@@ -0,0 +1,21 @@
package handler
import (
"testing"
"github.com/truewhile/MeBox/internal/service"
)
func TestSortRemoteSeriesCardsUpdatedAtUsesRemoteOrder(t *testing.T) {
cards := []service.SeriesCard{{Key: "newest"}, {Key: "middle"}, {Key: "oldest"}}
desc := sortRemoteSeriesCards(cards, service.MediaSortSpec{Field: "updated_at", Order: "desc"}, nil)
if desc[0].Key != "newest" || desc[1].Key != "middle" || desc[2].Key != "oldest" {
t.Fatalf("descending order = %q, %q, %q", desc[0].Key, desc[1].Key, desc[2].Key)
}
asc := sortRemoteSeriesCards(cards, service.MediaSortSpec{Field: "updated_at", Order: "asc"}, nil)
if asc[0].Key != "oldest" || asc[1].Key != "middle" || asc[2].Key != "newest" {
t.Fatalf("ascending order = %q, %q, %q", asc[0].Key, asc[1].Key, asc[2].Key)
}
}
+56
View File
@@ -0,0 +1,56 @@
// 直连流的同源转发:网页端需要读取视频帧的场景(VR 全景渲染走 WebGL 纹理)
// 不能使用会跳到网盘 CDN 的跨域直链,这里把直链改为服务端转发。
//
// 只在客户端显式带上 ?proxy=1 时生效,普通播放仍走原来的 302 直连,
// 避免把网盘流量无谓地压到服务器上。
package handler
import (
"errors"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service"
)
// wantSameOriginProxy 客户端是否要求把直连流改为服务端同源转发。
func wantSameOriginProxy(c *gin.Context) bool {
if c == nil {
return false
}
switch strings.ToLower(strings.TrimSpace(c.Query("proxy"))) {
case "1", "true", "yes":
return true
default:
return false
}
}
// proxySTRMStream 处理 STRM/网盘媒体的同源转发请求。返回 handled=false 表示
// 该媒体不需要(或无法)代理,调用方继续按原有逻辑处理。
func proxySTRMStream(c *gin.Context, svc *service.Container, m *model.Media) (bool, error) {
if c == nil || svc == nil || svc.Strm == nil || m == nil {
return false, nil
}
if !service.IsStrmMediaRow(m) {
return false, nil
}
if err := svc.Strm.ProxyMediaDirect(c.Request.Context(), c.Writer, c.Request, m); err != nil {
if errors.Is(err, service.ErrStrmProxyNotApplicable) {
return false, nil
}
return true, err
}
return true, nil
}
// writeProxyError 在尚未写入任何响应内容时回一个明确的网关错误。
func writeProxyError(c *gin.Context, err error) {
if c == nil || err == nil || c.Writer.Written() {
return
}
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
}
+89
View File
@@ -0,0 +1,89 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/service"
)
// Telegram 绑定与测试接口。
//
// 绑定刻意做成「网页生成一次性码 → 用户在 Bot 里发 /bind <码>」:服务端不需要
// 用户手工填写 chat id,也不需要站点暴露 Bot 命令以外任何能力。
type telegramBindCodePayload struct {
Code string `json:"code"`
ExpiresIn int `json:"expires_in_seconds"`
}
// startTelegramBindHandler 生成一次性绑定码。同一用户重复调用时旧码作废。
func startTelegramBindHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if svc.Telegram == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "telegram service unavailable"})
return
}
userID := sessionUserID(c)
code, err := svc.Telegram.StartBind(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, telegramBindCodePayload{Code: code, ExpiresIn: 300})
}
}
// getTelegramStatusHandler 返回绑定状态与脱敏会话 ID。
func getTelegramStatusHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if svc.Telegram == nil {
c.JSON(http.StatusOK, gin.H{"bound": false})
return
}
bound, masked := svc.Telegram.Status(c.Request.Context(), sessionUserID(c))
payload := gin.H{"bound": bound}
if bound {
payload["chat_id_masked"] = masked
}
c.JSON(http.StatusOK, payload)
}
}
// unbindTelegramHandler 解除当前用户的 Telegram 绑定。
func unbindTelegramHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if svc.Telegram == nil {
c.Status(http.StatusNoContent)
return
}
if err := svc.Telegram.Unbind(c.Request.Context(), sessionUserID(c)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
}
// testTelegramHandler 向管理员会话发送一条测试消息,用于验证 Token/会话 ID。
func testTelegramHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if svc.Telegram == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "telegram service unavailable"})
return
}
if !svc.Telegram.Configured(c.Request.Context()) {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": "请先启用 Telegram 通知并填写 Bot Token 与管理员 Chat ID",
})
return
}
if err := svc.Telegram.SendToAdminChecked(c.Request.Context(), "✅ MeBox 测试消息:通知通道工作正常。"); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
}
+226 -46
View File
@@ -11,8 +11,11 @@
package handler
import (
"context"
"net/http"
"sort"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
@@ -48,11 +51,16 @@ func historyListHandler(svc *service.Container) gin.HandlerFunc {
}
// historyStatsHandler returns aggregate watch time + completion counts
// for the caller. Used by the WatchHistoryPage hero card.
// for the caller. Used by the WatchHistoryPage hero card and the dedicated
// personal statistics page.
//
// 统计口径全部来自 PlaybackHistory 本身,不新增统计表:position_ms 是「已看
// 时长」的近似值,足以支撑趋势图;精确到秒的播放时长另有会话统计负责。
func historyStatsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid, _ := c.Get(middleware.CtxUserID)
userID := toString(uid)
ctx := c.Request.Context()
var total int64
_ = svc.Repo.DB.Model(&model.PlaybackHistory{}).
@@ -77,28 +85,209 @@ func historyStatsHandler(svc *service.Container) gin.HandlerFunc {
last = &lastT
}
visibility := mediaVisibilityForRequest(c, svc)
daily, byType, recent := historyStatsBreakdowns(ctx, svc, userID, visibility)
inProgress := total - completed
if inProgress < 0 {
inProgress = 0
}
c.JSON(http.StatusOK, gin.H{
"total": total,
"completed": completed,
"watched_ms": watchedMs,
"watched_hours": float64(watchedMs) / 1000.0 / 3600.0,
"last_watched": last,
"total": total,
"completed": completed,
"in_progress": inProgress,
"watched_ms": watchedMs,
"watched_hours": float64(watchedMs) / 1000.0 / 3600.0,
"last_watched": last,
"daily": daily,
"by_library_type": byType,
"recent": recent,
})
}
}
// historyStatsDailyDays 是趋势图回看的天数。
const historyStatsDailyDays = 30
// historyStatsRecentLimit 是「最近看过」返回的条数。
const historyStatsRecentLimit = 8
type historyDailyStat struct {
Day string `json:"day"`
WatchMs int64 `json:"watch_ms"`
Plays int64 `json:"plays"`
}
type historyTypeStat struct {
Type string `json:"type"`
WatchMs int64 `json:"watch_ms"`
Count int64 `json:"count"`
}
// historyStatsBreakdowns 产出每日趋势、按媒体库类型分布与最近记录。
//
// 分桶在 Go 里做而不是用 SQL 的日期函数:SQLite 的 strftime 与 PostgreSQL 的
// to_char 语法不同,写两份 SQL 会在方言差异上长期出错,而历史行数受用户规模
// 约束(每人一行一部媒体),一次全量读取是可以接受的。
//
// visibility 控制哪些媒体对调用者可见(播放档案、成人锁等)。
func historyStatsBreakdowns(ctx context.Context, svc *service.Container, userID string, visibility service.MediaVisibility) ([]historyDailyStat, []historyTypeStat, []map[string]any) {
daily := make([]historyDailyStat, 0, historyStatsDailyDays)
byType := make([]historyTypeStat, 0)
recent := make([]map[string]any, 0, historyStatsRecentLimit)
var rows []model.PlaybackHistory
if err := svc.Repo.DB.WithContext(ctx).
Where("user_id = ?", userID).
Order("watched_at desc").
Find(&rows).Error; err != nil || len(rows) == 0 {
return daily, byType, recent
}
// 每日趋势:只回看最近 N 天,且按「本地日」分桶,避免跨时区偏移。
now := time.Now()
cutoff := now.AddDate(0, 0, -(historyStatsDailyDays - 1))
startOfDay := func(t time.Time) time.Time {
local := t.In(time.Local)
return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, time.Local)
}
buckets := make(map[string]*historyDailyStat, historyStatsDailyDays)
for i := 0; i < historyStatsDailyDays; i++ {
day := startOfDay(cutoff).AddDate(0, 0, i).Format("2006-01-02")
buckets[day] = &historyDailyStat{Day: day}
}
for _, r := range rows {
watched := r.WatchedAt.In(time.Local)
if watched.Before(startOfDay(cutoff)) {
continue
}
if bucket, ok := buckets[watched.Format("2006-01-02")]; ok {
bucket.WatchMs += r.PositionMs
bucket.Plays++
}
}
for i := 0; i < historyStatsDailyDays; i++ {
day := startOfDay(cutoff).AddDate(0, 0, i).Format("2006-01-02")
if bucket, ok := buckets[day]; ok && bucket.Plays > 0 {
daily = append(daily, *bucket)
}
}
mediaIDs := make([]string, 0, len(rows))
for _, r := range rows {
mediaIDs = append(mediaIDs, r.MediaID)
}
var medias []model.Media
_ = svc.Repo.DB.WithContext(ctx).Where("id IN ?", mediaIDs).Find(&medias).Error
mediaByID := make(map[string]*model.Media, len(medias))
for i := range medias {
mediaByID[medias[i].ID] = &medias[i]
}
libraryTypes := make(map[string]string)
var libraries []model.Library
if svc.Repo.Library != nil {
if libs, err := svc.Repo.Library.List(ctx); err == nil {
libraries = libs
}
}
for _, lib := range libraries {
libraryTypes[lib.ID] = lib.Type
}
typeAcc := make(map[string]*historyTypeStat)
order := make([]string, 0, 4)
for _, r := range rows {
media := mediaByID[r.MediaID]
var key string
if media == nil {
// 媒体记录已删除(含 Emby 远程缓存失效):计入 "other" 桶而非丢弃,
// 这样类型分布总数才能与播放历史总数吻合。
key = "other"
} else {
// 如果调用者的可见性策略排除了该媒体,则跳过统计(visibility leak fix)。
if !visibility.Allows(media) {
continue
}
key = strings.TrimSpace(libraryTypes[media.LibraryID])
if key == "" {
key = "other"
}
}
acc, ok := typeAcc[key]
if !ok {
acc = &historyTypeStat{Type: key}
typeAcc[key] = acc
order = append(order, key)
}
acc.WatchMs += r.PositionMs
acc.Count++
}
// 顺序按观看时长降序,让「我主要在看什么」一眼可见。
for _, key := range order {
byType = append(byType, *typeAcc[key])
}
sort.SliceStable(byType, func(i, j int) bool {
if byType[i].WatchMs != byType[j].WatchMs {
return byType[i].WatchMs > byType[j].WatchMs
}
return byType[i].Type < byType[j].Type
})
for _, r := range rows {
if len(recent) >= historyStatsRecentLimit {
break
}
entry := map[string]any{"history": r}
if media := mediaByID[r.MediaID]; media != nil {
// 可见性检查:隐藏库或受档案限制的媒体不进入最近记录(visibility leak fix)。
if !visibility.Allows(media) {
continue
}
entry["media"] = media
} else if svc.EmbyRemote != nil && service.IsEmbyRemoteID(r.MediaID) {
// 尝试从 Emby 远端补全媒体详情,与 historyContinueHandler 保持相同策略。
mountID, remoteID, _ := service.DecodeEmbyRemoteID(r.MediaID)
mount, acct, resolveErr := svc.EmbyRemote.ResolveMount(ctx, mountID)
if resolveErr == nil && mount != nil && acct != nil {
remoteMedia, detailErr := svc.EmbyRemote.RemoteMediaDetail(ctx, mount, acct, remoteID)
if detailErr == nil && remoteMedia != nil {
if !visibility.Allows(remoteMedia) {
continue
}
entry["media"] = *remoteMedia
} else {
// 无法获取 Emby 媒体详情,跳过此条记录。
continue
}
} else {
// 挂载不可用,跳过。
continue
}
} else {
// 媒体记录不存在且无法 Emby 补全,跳过。
continue
}
recent = append(recent, entry)
}
return daily, byType, recent
}
// historyContinueHandler returns "Continue Watching" rows: incomplete
// items, most recent first.
func historyContinueHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid, _ := c.Get(middleware.CtxUserID)
userID := toString(uid)
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
if limit <= 0 || limit > 50 {
limit = 10
}
var rows []model.PlaybackHistory
if err := svc.Repo.DB.
Where("user_id = ? AND completed = ?", toString(uid), false).
Where("user_id = ? AND completed = ?", userID, false).
Order("watched_at desc").
Limit(limit).
Find(&rows).Error; err != nil {
@@ -116,61 +305,52 @@ func historyContinueHandler(svc *service.Container) gin.HandlerFunc {
}
mIdx := make(map[string]model.Media, len(media))
for _, m := range media {
if !mediaVisibleForRequest(c, svc, &m) {
continue
}
mIdx[m.ID] = m
}
out := make([]gin.H, 0, len(rows))
staleIDs := make([]string, 0)
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 {
if mediaVisibleForRequest(c, svc, rm) {
out = append(out, gin.H{
"history": r,
"media": *rm,
})
}
continue
}
}
}
fallback := fallbackHistoryMedia(r.MediaID)
if fallback != nil {
if ok {
if mediaVisibleForRequest(c, svc, &m) {
out = append(out, gin.H{
"history": r,
"media": *fallback,
"media": m,
})
}
continue
}
out = append(out, gin.H{
"history": r,
"media": m,
})
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(r.MediaID) {
mountID, remoteID, _ := service.DecodeEmbyRemoteID(r.MediaID)
mount, acct, resolveErr := svc.EmbyRemote.ResolveMount(c.Request.Context(), mountID)
if resolveErr == nil && mount != nil && acct != nil {
remoteMedia, detailErr := svc.EmbyRemote.RemoteMediaDetail(c.Request.Context(), mount, acct, remoteID)
if detailErr == nil && remoteMedia != nil {
if mediaVisibleForRequest(c, svc, remoteMedia) {
out = append(out, gin.H{
"history": r,
"media": *remoteMedia,
})
}
continue
}
}
}
// 媒体记录已不存在。继续返回占位卡只会让用户点击后遇到 404,
// 因此清理这条失效播放记录,不再占用继续观看列表。
staleIDs = append(staleIDs, r.MediaID)
}
if len(staleIDs) > 0 {
_ = svc.Repo.DB.WithContext(c.Request.Context()).Unscoped().
Where("user_id = ? AND media_id IN ?", userID, staleIDs).
Delete(&model.PlaybackHistory{}).Error
}
c.JSON(http.StatusOK, out)
}
}
func fallbackHistoryMedia(mediaID string) *model.Media {
if mediaID == "" {
return nil
}
title := "媒体"
if service.IsEmbyRemoteID(mediaID) {
title = "远程媒体"
}
return &model.Media{
Base: model.Base{ID: mediaID},
Title: title,
}
}
// historyDeleteHandler removes one or all history rows for the caller.
//
// DELETE /api/watch-history?media_id=xxx → delete just that media's row
@@ -0,0 +1,349 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
func newHistoryStatsEnv(t *testing.T) (*gin.Engine, *service.Container, string) {
t.Helper()
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(&model.User{}, &model.Library{}, &model.Media{}, &model.PlaybackHistory{}, &model.UserPermission{}); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
svc := &service.Container{Repo: repos, Log: zap.NewNop()}
svc.Permissions = service.NewPermissionService(zap.NewNop(), repos)
const userID = "user-1"
if err := repos.User.Create(context.Background(), &model.User{
Base: model.Base{ID: userID}, Username: "tester", PasswordHash: "x", Role: "user", IsActive: true,
}); err != nil {
t.Fatal(err)
}
router := gin.New()
authed := router.Group("/api", func(c *gin.Context) {
c.Set(middleware.CtxUserID, userID)
c.Next()
})
authed.GET("/watch-history/stats", historyStatsHandler(svc))
return router, svc, userID
}
type historyStatsPayload struct {
Total int64 `json:"total"`
Completed int64 `json:"completed"`
InProgress int64 `json:"in_progress"`
WatchedMs int64 `json:"watched_ms"`
WatchedHours float64 `json:"watched_hours"`
Daily []struct {
Day string `json:"day"`
WatchMs int64 `json:"watch_ms"`
Plays int64 `json:"plays"`
} `json:"daily"`
ByLibraryType []struct {
Type string `json:"type"`
WatchMs int64 `json:"watch_ms"`
Count int64 `json:"count"`
} `json:"by_library_type"`
Recent []struct {
Media *model.Media `json:"media"`
} `json:"recent"`
}
func fetchHistoryStats(t *testing.T, router *gin.Engine) historyStatsPayload {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/api/watch-history/stats", nil)
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 historyStatsPayload
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode %s: %v", w.Body.String(), err)
}
return payload
}
// 新字段必须提供每日聚合、库类型分布与在看数量,供个人统计页绘图。
func TestHistoryStatsIncludesDailyAndTypes(t *testing.T) {
router, svc, userID := newHistoryStatsEnv(t)
ctx := context.Background()
movieLib := &model.Library{Name: "电影", Path: "/media/movies", Type: "movie", Enabled: true}
if err := svc.Repo.Library.Create(ctx, movieLib); err != nil {
t.Fatal(err)
}
tvLib := &model.Library{Name: "剧集", Path: "/media/tv", Type: "tv", Enabled: true}
if err := svc.Repo.Library.Create(ctx, tvLib); err != nil {
t.Fatal(err)
}
yesterday := time.Now().Add(-24 * time.Hour)
today := time.Now().Add(-time.Hour)
rows := []struct {
media *model.Media
watchedAt time.Time
position int64
completed bool
}{
{
media: &model.Media{LibraryID: movieLib.ID, Title: "电影A", Path: "/media/movies/a.mkv"},
watchedAt: yesterday, position: 60000, completed: true,
},
{
media: &model.Media{LibraryID: tvLib.ID, Title: "剧B", Path: "/media/tv/b.mkv"},
watchedAt: today, position: 30000, completed: false,
},
}
for _, row := range rows {
if err := svc.Repo.DB.Create(row.media).Error; err != nil {
t.Fatal(err)
}
h := &model.PlaybackHistory{
UserID: userID, MediaID: row.media.ID, PositionMs: row.position,
DurationMs: 120000, WatchedAt: row.watchedAt, Completed: row.completed,
}
if err := svc.Repo.DB.Create(h).Error; err != nil {
t.Fatal(err)
}
}
payload := fetchHistoryStats(t, router)
if payload.Total != 2 {
t.Fatalf("total = %d, want 2", payload.Total)
}
if payload.Completed != 1 {
t.Fatalf("completed = %d, want 1", payload.Completed)
}
if payload.InProgress != 1 {
t.Fatalf("in_progress = %d, want 1", payload.InProgress)
}
if payload.WatchedMs != 90000 {
t.Fatalf("watched_ms = %d, want 90000", payload.WatchedMs)
}
if len(payload.Daily) != 2 {
t.Fatalf("daily = %+v, want 2 days", payload.Daily)
}
if len(payload.ByLibraryType) != 2 {
t.Fatalf("by_library_type = %+v, want 2 entries", payload.ByLibraryType)
}
if len(payload.Recent) != 2 {
t.Fatalf("recent = %d entries, want 2", len(payload.Recent))
}
if payload.Recent[0].Media == nil || payload.Recent[0].Media.Title != "剧B" {
t.Fatalf("recent[0] = %+v, want the most recent entry (剧B)", payload.Recent[0])
}
}
// 没有任何播放记录时,新字段要返回空数组而不是 null,前端无需额外判空。
func TestHistoryStatsEmptyProvidesEmptyArrays(t *testing.T) {
router, _, _ := newHistoryStatsEnv(t)
req := httptest.NewRequest(http.MethodGet, "/api/watch-history/stats", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
body := w.Body.String()
for _, field := range []string{`"daily":[]`, `"by_library_type":[]`, `"recent":[]`} {
if !containsSubstring(body, field) {
t.Fatalf("body = %s, want %s", body, field)
}
}
}
func containsSubstring(haystack, needle string) bool {
for i := 0; i+len(needle) <= len(haystack); i++ {
if haystack[i:i+len(needle)] == needle {
return true
}
}
return false
}
// TestHistoryStatsBreakdownsVisibilityFilter validates that historyStatsBreakdowns
// respects the caller's MediaVisibility: media in a hidden library must be absent
// from both the recent list and the by_library_type buckets.
func TestHistoryStatsBreakdownsVisibilityFilter(t *testing.T) {
_, svc, userID := newHistoryStatsEnv(t)
ctx := context.Background()
allowedLib := &model.Library{Name: "允许库", Path: "/media/allowed", Type: "movie", Enabled: true}
hiddenLib := &model.Library{Name: "隐藏库", Path: "/media/hidden", Type: "tv", Enabled: true}
if err := svc.Repo.Library.Create(ctx, allowedLib); err != nil {
t.Fatal(err)
}
if err := svc.Repo.Library.Create(ctx, hiddenLib); err != nil {
t.Fatal(err)
}
allowedMedia := &model.Media{LibraryID: allowedLib.ID, Title: "允许媒体", Path: "/media/allowed/a.mkv"}
hiddenMedia := &model.Media{LibraryID: hiddenLib.ID, Title: "隐藏媒体", Path: "/media/hidden/b.mkv"}
if err := svc.Repo.DB.Create(allowedMedia).Error; err != nil {
t.Fatal(err)
}
if err := svc.Repo.DB.Create(hiddenMedia).Error; err != nil {
t.Fatal(err)
}
now := time.Now()
for _, mid := range []string{allowedMedia.ID, hiddenMedia.ID} {
h := &model.PlaybackHistory{
UserID: userID, MediaID: mid, PositionMs: 10000,
DurationMs: 100000, WatchedAt: now, Completed: false,
}
if err := svc.Repo.DB.Create(h).Error; err != nil {
t.Fatal(err)
}
}
// visibility that hides hiddenLib
vis := service.MediaVisibility{
HiddenLibraryIDs: []string{hiddenLib.ID},
}
_, byType, recent := historyStatsBreakdowns(ctx, svc, userID, vis)
// recent must contain only the allowed media
for _, entry := range recent {
m, ok := entry["media"]
if !ok {
t.Fatal("recent entry missing media field")
}
switch med := m.(type) {
case *model.Media:
if med.LibraryID == hiddenLib.ID {
t.Fatalf("hidden media appeared in recent: %s", med.Title)
}
case model.Media:
if med.LibraryID == hiddenLib.ID {
t.Fatalf("hidden media appeared in recent: %s", med.Title)
}
}
}
if len(recent) != 1 {
t.Fatalf("recent length = %d, want 1 (hidden entry must be excluded)", len(recent))
}
// by_library_type must not contain the hidden library's type ("tv")
for _, bt := range byType {
if bt.Type == "tv" {
t.Fatalf("hidden library type 'tv' appeared in by_library_type (count=%d)", bt.Count)
}
}
}
// TestHistoryStatsBreakdownsNilMediaCountsAsOther confirms that a history row
// whose media has been deleted (nil lookup) is counted under the "other" type
// bucket rather than silently dropped.
func TestHistoryStatsBreakdownsNilMediaCountsAsOther(t *testing.T) {
_, svc, userID := newHistoryStatsEnv(t)
ctx := context.Background()
// Insert a history row whose media_id does not correspond to any Media row.
ghost := &model.PlaybackHistory{
UserID: userID,
MediaID: "ghost-media-id",
PositionMs: 5000,
DurationMs: 50000,
WatchedAt: time.Now(),
Completed: false,
}
if err := svc.Repo.DB.Create(ghost).Error; err != nil {
t.Fatal(err)
}
vis := service.MediaVisibility{} // unrestricted
_, byType, _ := historyStatsBreakdowns(ctx, svc, userID, vis)
var otherEntry *historyTypeStat
for i := range byType {
if byType[i].Type == "other" {
otherEntry = &byType[i]
break
}
}
if otherEntry == nil {
t.Fatalf("expected 'other' bucket for nil-media history row, got %+v", byType)
}
if otherEntry.Count != 1 {
t.Fatalf("other.Count = %d, want 1", otherEntry.Count)
}
}
// TestHistoryStatsPermissionDeny checks that a user without can_view_history
// receives HTTP 403 from the gated route.
func TestHistoryStatsPermissionDeny(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(
&model.User{}, &model.Library{}, &model.Media{},
&model.PlaybackHistory{}, &model.UserPermission{},
); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
svc := &service.Container{Repo: repos, Log: zap.NewNop()}
svc.Permissions = service.NewPermissionService(zap.NewNop(), repos)
const userID = "user-noperm"
if err := repos.User.Create(context.Background(), &model.User{
Base: model.Base{ID: userID}, Username: "noperm", PasswordHash: "x", Role: "user", IsActive: true,
}); err != nil {
t.Fatal(err)
}
// Explicitly deny can_view_history for this user.
// First seed defaults (Effective will create the row with defaults), then
// update to deny via Save which uses an explicit map update path in the repo.
if _, err := svc.Permissions.Effective(context.Background(), userID); err != nil {
t.Fatalf("seed permissions: %v", err)
}
denyPerm := &model.UserPermission{UserID: userID, CanViewHistory: false}
if err := svc.Permissions.Save(context.Background(), userID, denyPerm); err != nil {
t.Fatalf("save permission: %v", err)
}
router := gin.New()
authed := router.Group("/api", func(c *gin.Context) {
c.Set(middleware.CtxUserID, userID)
c.Set(middleware.CtxUserRole, "user")
c.Next()
})
authed.GET("/watch-history/stats", requirePermission(svc, "can_view_history"), historyStatsHandler(svc))
req := httptest.NewRequest(http.MethodGet, "/api/watch-history/stats", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403 for user without can_view_history", w.Code)
}
}
+105
View File
@@ -0,0 +1,105 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
func TestHistoryContinueRemovesMissingMedia(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.PlaybackHistory{}, &model.Media{}); err != nil {
t.Fatal(err)
}
valid := model.Media{
Base: model.Base{ID: "media-valid"},
Title: "有效影片",
Path: "/media/valid.mkv",
}
if err := db.Create(&valid).Error; err != nil {
t.Fatal(err)
}
now := time.Now()
if err := db.Create(&model.PlaybackHistory{
Base: model.Base{ID: "history-valid"},
UserID: "user-1",
MediaID: valid.ID,
PositionMs: 30_000,
DurationMs: 120_000,
WatchedAt: now.Add(-time.Minute),
}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.PlaybackHistory{
Base: model.Base{ID: "history-stale"},
UserID: "user-1",
MediaID: "media-deleted",
PositionMs: 60_000,
DurationMs: 120_000,
WatchedAt: now,
}).Error; err != nil {
t.Fatal(err)
}
repos := repository.New(db)
svc := &service.Container{
Log: zap.NewNop(),
Repo: repos,
Playback: service.NewPlaybackService(zap.NewNop(), repos),
}
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set(middleware.CtxUserID, "user-1")
c.Next()
})
router.GET("/watch-history/continue", historyContinueHandler(svc))
req := httptest.NewRequest(http.MethodGet, "/watch-history/continue?limit=10", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var body []struct {
History model.PlaybackHistory `json:"history"`
Media model.Media `json:"media"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if len(body) != 1 {
t.Fatalf("continue watching rows = %d, want 1: %s", len(body), rec.Body.String())
}
if body[0].Media.ID != valid.ID || body[0].History.ID != "history-valid" {
t.Fatalf("unexpected row: %#v", body[0])
}
var staleCount int64
if err := db.Unscoped().Model(&model.PlaybackHistory{}).
Where("user_id = ? AND media_id = ?", "user-1", "media-deleted").
Count(&staleCount).Error; err != nil {
t.Fatal(err)
}
if staleCount != 0 {
t.Fatalf("stale history rows remaining = %d, want 0", staleCount)
}
}
+33 -32
View File
@@ -26,38 +26,39 @@ type LibraryRoot struct {
// Media 是单个可播放项。剧集链接到 SeriesID;电影 SeriesID == ""。
type Media struct {
Base
LibraryID string `gorm:"index;size:36;index:idx_media_library_release,priority:1" json:"library_id"`
LibraryRootID string `gorm:"index;size:36" json:"library_root_id,omitempty"`
SeriesID string `gorm:"index;size:128" json:"series_id,omitempty"`
Title string `gorm:"size:255;not null" json:"title"`
OriginalName string `gorm:"size:255" json:"original_name,omitempty"`
EpisodeTitle string `gorm:"size:255" json:"episode_title,omitempty"`
Path string `gorm:"uniqueIndex;size:1024;not null" json:"path"`
RelativePath string `gorm:"size:1024" json:"relative_path,omitempty"`
SizeBytes int64 `json:"size_bytes"`
DurationSec int `json:"duration_sec"`
Width int `json:"width"`
Height int `json:"height"`
VideoCodec string `gorm:"size:32" json:"video_codec,omitempty"`
AudioCodec string `gorm:"size:32" json:"audio_codec,omitempty"`
Container string `gorm:"size:128" json:"container,omitempty"`
PosterURL string `gorm:"size:1024" json:"poster_url,omitempty"`
BackdropURL string `gorm:"size:1024" json:"backdrop_url,omitempty"`
Overview string `gorm:"type:text" json:"overview,omitempty"`
Rating float32 `json:"rating"`
Year int `json:"year"`
ReleaseDate string `gorm:"size:10;index:idx_media_library_release,priority:2" json:"release_date,omitempty"`
SeasonNum int `json:"season_num"`
EpisodeNum int `json:"episode_num"`
ScrapeStatus string `gorm:"size:16;default:pending" json:"scrape_status"`
TMDbID int `json:"tmdb_id"`
BangumiID int `json:"bangumi_id"`
DoubanID string `gorm:"column:douban_id;size:32" json:"douban_id,omitempty"`
TheTVDBID string `gorm:"column:thetvdb_id;size:64" json:"thetvdb_id,omitempty"`
Languages string `gorm:"size:64" json:"languages,omitempty"` // 逗号分隔的 ISO 639-1 代码,如 "zh,en"
Countries string `gorm:"size:128" json:"countries,omitempty"` // 逗号分隔的 ISO 3166-1,如 "CN,US"
Genres string `gorm:"type:text" json:"genres,omitempty"` // 逗号分隔的类型名,如 "Action,Animation"
NSFW bool `gorm:"default:false" json:"nsfw"`
LibraryID string `gorm:"index;size:36;index:idx_media_library_release,priority:1" json:"library_id"`
LibraryRootID string `gorm:"index;size:36" json:"library_root_id,omitempty"`
SeriesID string `gorm:"index;size:128" json:"series_id,omitempty"`
Title string `gorm:"size:255;not null" json:"title"`
OriginalName string `gorm:"size:255" json:"original_name,omitempty"`
EpisodeTitle string `gorm:"size:255" json:"episode_title,omitempty"`
Path string `gorm:"uniqueIndex;size:1024;not null" json:"path"`
RelativePath string `gorm:"size:1024" json:"relative_path,omitempty"`
SizeBytes int64 `json:"size_bytes"`
DurationSec int `json:"duration_sec"`
Width int `json:"width"`
Height int `json:"height"`
VideoCodec string `gorm:"size:32" json:"video_codec,omitempty"`
AudioCodec string `gorm:"size:32" json:"audio_codec,omitempty"`
Container string `gorm:"size:128" json:"container,omitempty"`
PosterURL string `gorm:"size:1024" json:"poster_url,omitempty"`
BackdropURL string `gorm:"size:1024" json:"backdrop_url,omitempty"`
Overview string `gorm:"type:text" json:"overview,omitempty"`
Rating float32 `json:"rating"`
Year int `json:"year"`
ReleaseDate string `gorm:"size:10;index:idx_media_library_release,priority:2" json:"release_date,omitempty"`
SeasonNum int `json:"season_num"`
EpisodeNum int `json:"episode_num"`
EpisodeFraction float64 `gorm:"default:0" json:"episode_fraction,omitempty"` // 集号小数部分(S01E11.5 → 0.5);Emby 的 IndexNumber 只能是整数,小数单独存放
ScrapeStatus string `gorm:"size:16;default:pending" json:"scrape_status"`
TMDbID int `json:"tmdb_id"`
BangumiID int `json:"bangumi_id"`
DoubanID string `gorm:"column:douban_id;size:32" json:"douban_id,omitempty"`
TheTVDBID string `gorm:"column:thetvdb_id;size:64" json:"thetvdb_id,omitempty"`
Languages string `gorm:"size:64" json:"languages,omitempty"` // 逗号分隔的 ISO 639-1 代码,如 "zh,en"
Countries string `gorm:"size:128" json:"countries,omitempty"` // 逗号分隔的 ISO 3166-1,如 "CN,US"
Genres string `gorm:"type:text" json:"genres,omitempty"` // 逗号分隔的类型名,如 "Action,Animation"
NSFW bool `gorm:"default:false" json:"nsfw"`
// STRMURL is the indirection target for .strm files: when present the
// stream handler redirects to it instead of opening the local file.
+118
View File
@@ -0,0 +1,118 @@
package model
import (
"encoding/json"
"strings"
)
// MaxLibraryTags 是单个用户可创建的标签数量上限,避免恶意写入过大的 JSON。
const MaxLibraryTags = 50
// MaxLibraryTagNameLen 是单个标签名的最大字符长度(按 rune 计数)。
const MaxLibraryTagNameLen = 24
// LibraryTagSet 是用户自定义的媒体库标签分组。
// LibraryIDs 保存该标签下媒体库的 ID(含远程 Emby 挂载库的 embyremote~ 形式),
// 顺序即媒体库在该标签内的展示顺序。
type LibraryTagSet struct {
Name string `json:"name"`
LibraryIDs []string `json:"library_ids"`
}
// DecodeLibraryTags 解析 LibraryTags 字段,忽略损坏的数据。
func (u *User) DecodeLibraryTags() []LibraryTagSet {
if u == nil || strings.TrimSpace(u.LibraryTags) == "" {
return nil
}
var tags []LibraryTagSet
if err := json.Unmarshal([]byte(u.LibraryTags), &tags); err != nil {
return nil
}
out := make([]LibraryTagSet, 0, len(tags))
for _, tag := range NormalizeLibraryTags(tags) {
out = append(out, tag)
}
if len(out) == 0 {
return nil
}
return out
}
// EncodeLibraryTags 把标签集合序列化为可写入 LibraryTags 字段的 JSON 字符串。
// 空集合序列化为空字符串,便于用零值表达"没有标签"。
func EncodeLibraryTags(tags []LibraryTagSet) (string, error) {
if len(tags) == 0 {
return "", nil
}
raw, err := json.Marshal(tags)
if err != nil {
return "", err
}
return string(raw), nil
}
// NormalizeLibraryTags 清洗标签集合:去空白、丢弃空名标签、去掉标签名与
// 标签内媒体库 ID 的重复项,并把标签名重复的项合并。保持传入顺序。
func NormalizeLibraryTags(tags []LibraryTagSet) []LibraryTagSet {
if len(tags) == 0 {
return nil
}
out := make([]LibraryTagSet, 0, len(tags))
indexByName := make(map[string]int, len(tags))
for _, tag := range tags {
name := TruncateLibraryTagName(tag.Name)
if name == "" {
continue
}
if len(out) >= MaxLibraryTags {
break
}
key := strings.ToLower(name)
pos, exists := indexByName[key]
if !exists {
if len(out) >= MaxLibraryTags {
break
}
out = append(out, LibraryTagSet{Name: name, LibraryIDs: []string{}})
pos = len(out) - 1
indexByName[key] = pos
}
seen := make(map[string]struct{}, len(out[pos].LibraryIDs))
for _, id := range out[pos].LibraryIDs {
seen[id] = struct{}{}
}
for _, id := range tag.LibraryIDs {
trimmed := strings.TrimSpace(id)
if trimmed == "" {
continue
}
if _, ok := seen[trimmed]; ok {
continue
}
seen[trimmed] = struct{}{}
out[pos].LibraryIDs = append(out[pos].LibraryIDs, trimmed)
}
}
if len(out) == 0 {
return nil
}
for i := range out {
if out[i].LibraryIDs == nil {
out[i].LibraryIDs = []string{}
}
}
return out
}
// TruncateLibraryTagName 去掉首尾空白并按 rune 截断到长度上限。
func TruncateLibraryTagName(name string) string {
name = strings.TrimSpace(name)
if name == "" {
return ""
}
runes := []rune(name)
if len(runes) > MaxLibraryTagNameLen {
runes = runes[:MaxLibraryTagNameLen]
}
return strings.TrimSpace(string(runes))
}
+79
View File
@@ -0,0 +1,79 @@
package model
import "testing"
func TestNormalizeLibraryTagsMergesDuplicatesAndTrims(t *testing.T) {
tags := []LibraryTagSet{
{Name: " 动画 ", LibraryIDs: []string{" a ", "", "b", "a"}},
{Name: "动画", LibraryIDs: []string{"c", "b"}},
{Name: " ", LibraryIDs: []string{"x"}},
{Name: "电影", LibraryIDs: nil},
}
got := NormalizeLibraryTags(tags)
if len(got) != 2 {
t.Fatalf("NormalizeLibraryTags len = %d, want 2 (%#v)", len(got), got)
}
if got[0].Name != "动画" {
t.Fatalf("first tag name = %q, want 动画", got[0].Name)
}
want := []string{"a", "b", "c"}
if len(got[0].LibraryIDs) != len(want) {
t.Fatalf("first tag ids = %v, want %v", got[0].LibraryIDs, want)
}
for i := range want {
if got[0].LibraryIDs[i] != want[i] {
t.Fatalf("first tag ids = %v, want %v", got[0].LibraryIDs, want)
}
}
if got[1].Name != "电影" || got[1].LibraryIDs == nil {
t.Fatalf("empty tag should survive with an empty non-nil id list: %#v", got[1])
}
}
func TestNormalizeLibraryTagsCapsCountAndNameLength(t *testing.T) {
long := make([]rune, MaxLibraryTagNameLen+10)
for i := range long {
long[i] = 'x'
}
got := NormalizeLibraryTags([]LibraryTagSet{{Name: string(long)}})
if len(got) != 1 {
t.Fatalf("len = %d, want 1", len(got))
}
if runes := []rune(got[0].Name); len(runes) != MaxLibraryTagNameLen {
t.Fatalf("name length = %d, want %d", len(runes), MaxLibraryTagNameLen)
}
many := make([]LibraryTagSet, 0, MaxLibraryTags+5)
for i := 0; i < MaxLibraryTags+5; i++ {
many = append(many, LibraryTagSet{Name: string(rune('a' + i%26)) + "-" + string(rune('a'+i/26))})
}
if capped := NormalizeLibraryTags(many); len(capped) > MaxLibraryTags {
t.Fatalf("capped len = %d, want <= %d", len(capped), MaxLibraryTags)
}
}
func TestEncodeDecodeLibraryTagsRoundTrip(t *testing.T) {
user := &User{}
if encoded, err := EncodeLibraryTags(nil); err != nil || encoded != "" {
t.Fatalf("EncodeLibraryTags(nil) = %q, %v; want \"\", nil", encoded, err)
}
raw, err := EncodeLibraryTags([]LibraryTagSet{{Name: "动画", LibraryIDs: []string{"lib-1"}}})
if err != nil {
t.Fatalf("EncodeLibraryTags: %v", err)
}
user.LibraryTags = raw
decoded := user.DecodeLibraryTags()
if len(decoded) != 1 || decoded[0].Name != "动画" || len(decoded[0].LibraryIDs) != 1 || decoded[0].LibraryIDs[0] != "lib-1" {
t.Fatalf("DecodeLibraryTags = %#v", decoded)
}
user.LibraryTags = "{not json"
if decoded := user.DecodeLibraryTags(); decoded != nil {
t.Fatalf("corrupt payload should decode to nil, got %#v", decoded)
}
user.PopulateComputedFields()
if user.LibraryTagList != nil {
t.Fatalf("PopulateComputedFields should mirror DecodeLibraryTags, got %#v", user.LibraryTagList)
}
}
+42
View File
@@ -0,0 +1,42 @@
package model
import "time"
// MediaProbe 是一次 ffprobe 全量探测(容器 / 轨道 / 内嵌章节)的结果缓存。
//
// 它存在的理由:探测一次要 3~4 秒——远端直链更慢,因为要跨洋跑三次 HTTP
// 事务。播放链路绝不能等它,所以第一次播放只起后台任务,结果落库后由后续请求
// 与「跳过片头」的章节数据直接读库。
//
// Payload 刻意只保存裁剪后的字段:ffprobe 原始输出里的 format.filename 是解析
// 后的播放直链(带网盘签名与 pickcode),原样落库等于把可直接下载的链接留在
// 数据库里,所以只保留与技术信息有关的字段。
type MediaProbe struct {
Base
MediaID string `gorm:"uniqueIndex;size:128;not null" json:"media_id"`
// Signature 是「探的是哪个文件」的指纹(哈希):本地文件取路径 + 大小 +
// 修改时间,STRM / 云盘取固化的播放目标。文件换了就说明缓存不再对应当前
// 内容,需要重探。
Signature string `gorm:"size:64" json:"signature,omitempty"`
// Source 记录输入形态:local | strm。
Source string `gorm:"size:16" json:"source,omitempty"`
Container string `gorm:"size:64" json:"container,omitempty"`
DurationSec int `json:"duration_sec"`
BitRate int64 `json:"bit_rate,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
VideoCodec string `gorm:"size:64" json:"video_codec,omitempty"`
AudioCodec string `gorm:"size:64" json:"audio_codec,omitempty"`
VideoStreams int `json:"video_streams"`
AudioStreams int `json:"audio_streams"`
SubtitleStreams int `json:"subtitle_streams"`
ChapterCount int `json:"chapter_count"`
// Payload 是供详情页展示的裁剪后 JSON(容器 + 每路轨道 + 章节)。
Payload string `gorm:"type:text" json:"payload,omitempty"`
// ProbedAt 是最近一次探测的时刻。LastError 非空表示这次探测失败;失败只更新
// 这两个字段,不会覆盖此前成功的 Payload 与已经落库的章节片段。
ProbedAt time.Time `json:"probed_at"`
LastError string `gorm:"size:512" json:"last_error,omitempty"`
}
+39
View File
@@ -0,0 +1,39 @@
package model
import "time"
// 片段类型与 TheIntroDB 的返回字段一一对应。客户端按 kind 决定按钮文案
// (片头 / 回顾 / 片尾 / 预告),不依赖具体来源。
const (
SegmentKindIntro = "intro"
SegmentKindRecap = "recap"
SegmentKindCredits = "credits"
SegmentKindPreview = "preview"
)
// MediaSegment 是媒体源时间轴上一个可被跳过的区间(片头 / 回顾 / 片尾 / 预告)。
// 提供方(当前为 TheIntroDB)填充,播放器消费后向用户提供「跳过片头」。
type MediaSegment struct {
Base
MediaID string `gorm:"index;size:128;not null;uniqueIndex:uniq_media_segment" json:"media_id"`
SeriesID string `gorm:"index;size:128" json:"series_id,omitempty"`
Kind string `gorm:"size:16;not null;uniqueIndex:uniq_media_segment" json:"kind"`
// StartMs/EndMs 是媒体源时间轴上的毫秒绝对值。EndMs 为 0 表示区间一直延续到
// 片尾(TheIntroDB 对末段返回 end_ms: null),由客户端结合媒体总时长补齐。
StartMs int64 `gorm:"not null;default:0;uniqueIndex:uniq_media_segment" json:"start_ms"`
EndMs int64 `gorm:"not null;default:0" json:"end_ms"`
// Source 记录数据来源,让同一媒体上多来源共存、以及将来的人工覆盖成为可能。
// 它必须参与唯一索引:否则「外部数据」与「人工修正」给出同一区间时会撞索引。
Source string `gorm:"size:32;not null;default:'';uniqueIndex:uniq_media_segment" json:"source,omitempty"`
}
// MediaSegmentFetch 记录「某媒体的片段是否已向某来源查询过」。
// 单独建表是为了能缓存「查不到」这个结果:没有负缓存的话,每次播放一部社区库里
// 还没有数据的影片都会重新打一次外网。
type MediaSegmentFetch struct {
Base
MediaID string `gorm:"index;size:128;not null;uniqueIndex:uniq_media_segment_fetch" json:"media_id"`
Source string `gorm:"size:32;not null;uniqueIndex:uniq_media_segment_fetch" json:"source"`
FetchedAt time.Time `json:"fetched_at"`
Found bool `json:"found"`
}
+3
View File
@@ -38,6 +38,9 @@ func AllModels() []interface{} {
&Series{},
&Media{},
&PlaybackHistory{},
&MediaSegment{},
&MediaSegmentFetch{},
&MediaProbe{},
&Favorite{},
&Playlist{},
&PlaylistItem{},
+21 -8
View File
@@ -28,24 +28,36 @@ type User struct {
// PinnedLibraryIDs 存储用户置顶的媒体库 ID 列表(JSON 字符串),顺序即置顶优先级。
PinnedLibraryIDs string `gorm:"type:text" json:"-"`
PinnedLibraryList []string `gorm:"-" json:"pinned_library_ids,omitempty"`
// LibraryTags 存储用户自定义的媒体库标签分组(JSON 字符串),
// 形如 [{"name":"动画","library_ids":["lib-1","lib-2"]}]。标签属于用户本人,
// 用于在媒体库页把同一标签下的媒体库聚合到一起。
LibraryTags string `gorm:"type:text" json:"-"`
LibraryTagList []LibraryTagSet `gorm:"-" json:"library_tags,omitempty"`
// SubtitleChineseMode 是网页播放器外挂字幕的简繁转换偏好:
// original / simplified / traditional。
SubtitleChineseMode string `gorm:"size:16;not null;default:original" json:"subtitle_chinese_mode"`
// 网页播放器偏好按用户存储,切换媒体对象后继续沿用。
PlayerVolume float64 `gorm:"not null;default:1" json:"player_volume"`
DanmakuEnabled bool `gorm:"not null;default:true" json:"danmaku_enabled"`
DanmakuOpacity float64 `gorm:"not null;default:1" json:"danmaku_opacity"`
DanmakuFontSize int `gorm:"not null;default:24" json:"danmaku_font_size"`
DanmakuArea float64 `gorm:"not null;default:1" json:"danmaku_area"`
DanmakuMergeSources bool `gorm:"not null;default:false" json:"danmaku_merge_sources"`
DanmakuSource string `gorm:"size:512" json:"danmaku_source,omitempty"`
DanmakuAppID string `gorm:"size:128" json:"danmaku_app_id,omitempty"`
// 播放倍速不在此列:它只作用于当前视频,不进数据库。
PlayerVolume float64 `gorm:"not null;default:1" json:"player_volume"`
// PlayerVr360GuideSeen 记录用户是否已经看过 VR 全景播放的首次操作说明,
// 按用户保存:看过一次之后不再弹出。
PlayerVr360GuideSeen bool `gorm:"not null;default:false" json:"player_vr360_guide_seen"`
DanmakuEnabled bool `gorm:"not null;default:true" json:"danmaku_enabled"`
DanmakuOpacity float64 `gorm:"not null;default:1" json:"danmaku_opacity"`
DanmakuFontSize int `gorm:"not null;default:24" json:"danmaku_font_size"`
DanmakuArea float64 `gorm:"not null;default:1" json:"danmaku_area"`
DanmakuMergeSources bool `gorm:"not null;default:false" json:"danmaku_merge_sources"`
DanmakuSource string `gorm:"size:512" json:"danmaku_source,omitempty"`
DanmakuAppID string `gorm:"size:128" json:"danmaku_app_id,omitempty"`
// DanmakuAppKey 只在服务端读取并用于请求签名,绝不通过用户资料接口下发。
DanmakuAppKey string `gorm:"size:256" json:"-"`
// 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.
ExpiredAt *time.Time `json:"expired_at,omitempty"`
// TelegramChatID 是用户绑定的 Telegram 会话 ID,用于接收账号与设备通知。
// 为空表示未绑定;绑定走个人资料页生成的一次性码 + Bot /bind 命令。
TelegramChatID string `gorm:"size:64" json:"telegram_chat_id,omitempty"`
// ShareWarnings counts anti-account-sharing warnings, mainly device
// fingerprint mismatches. Once it exceeds the configured threshold a
// re-offence disables the account until an admin re-enables it.
@@ -102,4 +114,5 @@ func (u *User) PopulateComputedFields() {
}
u.AllowedLibraryList = u.DecodeAllowedLibraryIDs()
u.PinnedLibraryList = u.DecodePinnedLibraryIDs()
u.LibraryTagList = u.DecodeLibraryTags()
}
+24
View File
@@ -0,0 +1,24 @@
package model
import (
"sync"
"testing"
"gorm.io/gorm/schema"
)
// TelegramChatID 是 Telegram 通知的绑定目标:Size 必须容得下真实 chat id
// (群/频道 id 为负数且位数更长),因此下限设为 64。
func TestUserTelegramChatIDFieldSize(t *testing.T) {
parsed, err := schema.Parse(&User{}, &sync.Map{}, schema.NamingStrategy{})
if err != nil {
t.Fatal(err)
}
field := parsed.LookUpField("TelegramChatID")
if field == nil {
t.Fatal("TelegramChatID field not found")
}
if field.Size < 64 {
t.Fatalf("TelegramChatID size = %d, want at least 64", field.Size)
}
}
+174
View File
@@ -0,0 +1,174 @@
package repository
import (
"context"
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/truewhile/MeBox/internal/model"
)
func newMediaFilterTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Media{}, &model.PlaybackHistory{}); err != nil {
t.Fatal(err)
}
return db
}
func seedFilterMedia(t *testing.T, db *gorm.DB, rows ...*model.Media) {
t.Helper()
for _, row := range rows {
if err := db.WithContext(context.Background()).Create(row).Error; err != nil {
t.Fatal(err)
}
}
}
func listFiltered(t *testing.T, db *gorm.DB, filter MediaQueryFilter) []string {
t.Helper()
var rows []model.Media
q := db.WithContext(context.Background()).Model(&model.Media{})
q = applyMediaQueryFilter(q, filter)
if err := q.Order("title asc").Find(&rows).Error; err != nil {
t.Fatal(err)
}
out := make([]string, 0, len(rows))
for _, row := range rows {
out = append(out, row.Title)
}
return out
}
func hasTitle(items []string, want string) bool {
for _, item := range items {
if item == want {
return true
}
}
return false
}
// 多个类型之间是「或」:勾选 Action 与 Comedy 应同时命中两类。
func TestFilterByGenreOR(t *testing.T) {
db := newMediaFilterTestDB(t)
seedFilterMedia(t, db,
&model.Media{Title: "动作", Genres: "Action", Path: "/a.mkv", LibraryID: "lib-1"},
&model.Media{Title: "喜剧", Genres: "Comedy", Path: "/b.mkv", LibraryID: "lib-1"},
&model.Media{Title: "剧情", Genres: "Drama", Path: "/c.mkv", LibraryID: "lib-1"},
)
got := listFiltered(t, db, MediaQueryFilter{IncludeNSFW: true, Genres: []string{"Action", "Comedy"}})
if !hasTitle(got, "动作") || !hasTitle(got, "喜剧") {
t.Fatalf("result = %v, want both 动作 and 喜剧", got)
}
if hasTitle(got, "剧情") {
t.Fatalf("result = %v, must not contain 剧情", got)
}
}
// 类型匹配必须是整词匹配:搜 "Action" 不能命中 "ActionComedy" 这类拼接值。
func TestFilterByGenreDoesNotMatchSubstring(t *testing.T) {
db := newMediaFilterTestDB(t)
seedFilterMedia(t, db,
&model.Media{Title: "精确", Genres: "Action,Drama", Path: "/a.mkv", LibraryID: "lib-1"},
&model.Media{Title: "拼接", Genres: "ActionComedy", Path: "/b.mkv", LibraryID: "lib-1"},
)
got := listFiltered(t, db, MediaQueryFilter{IncludeNSFW: true, Genres: []string{"Action"}})
if !hasTitle(got, "精确") {
t.Fatalf("result = %v, want 精确", got)
}
if hasTitle(got, "拼接") {
t.Fatalf("result = %v, must not match ActionComedy for Action", got)
}
}
func TestFilterYearAndRating(t *testing.T) {
db := newMediaFilterTestDB(t)
seedFilterMedia(t, db,
&model.Media{Title: "老片", Year: 1995, Rating: 9, Path: "/a.mkv", LibraryID: "lib-1"},
&model.Media{Title: "中年", Year: 2010, Rating: 5, Path: "/b.mkv", LibraryID: "lib-1"},
&model.Media{Title: "新片", Year: 2023, Rating: 8, Path: "/c.mkv", LibraryID: "lib-1"},
)
got := listFiltered(t, db, MediaQueryFilter{IncludeNSFW: true, YearMin: 2000, YearMax: 2020})
if len(got) != 1 || got[0] != "中年" {
t.Fatalf("year filter result = %v, want [中年]", got)
}
got = listFiltered(t, db, MediaQueryFilter{IncludeNSFW: true, RatingMin: 8})
if len(got) != 2 {
t.Fatalf("rating filter result = %v, want 2 entries", got)
}
}
// 「未观看」的语义是「没有标记看完的记录」:看了一半的仍应出现。
func TestFilterUnwatchedExcludesCompleted(t *testing.T) {
db := newMediaFilterTestDB(t)
seedFilterMedia(t, db,
&model.Media{Base: model.Base{ID: "m-done"}, Title: "看完", Path: "/a.mkv", LibraryID: "lib-1"},
&model.Media{Base: model.Base{ID: "m-half"}, Title: "看一半", Path: "/b.mkv", LibraryID: "lib-1"},
&model.Media{Base: model.Base{ID: "m-new"}, Title: "没看过", Path: "/c.mkv", LibraryID: "lib-1"},
)
ctx := context.Background()
for _, h := range []*model.PlaybackHistory{
{UserID: "u1", MediaID: "m-done", Completed: true},
{UserID: "u1", MediaID: "m-half", Completed: false},
// 别人的完播记录不应影响本人筛选。
{UserID: "u2", MediaID: "m-new", Completed: true},
} {
if err := db.WithContext(ctx).Create(h).Error; err != nil {
t.Fatal(err)
}
}
got := listFiltered(t, db, MediaQueryFilter{
IncludeNSFW: true, UnwatchedOnly: true, UnwatchedUserID: "u1",
})
if hasTitle(got, "看完") {
t.Fatalf("result = %v, must exclude completed media", got)
}
if !hasTitle(got, "看一半") || !hasTitle(got, "没看过") {
t.Fatalf("result = %v, want both 看一半 and 没看过", got)
}
}
// 多词类型(如 "Science Fiction"):列侧 SQL 会 REPLACE 掉空格,参数侧也必须同步
// 去掉空格,两侧对称才能命中。
func TestFilterByGenreMultiWordStripsSpaces(t *testing.T) {
db := newMediaFilterTestDB(t)
seedFilterMedia(t, db,
&model.Media{Title: "科幻", Genres: "Science Fiction,Drama", Path: "/a.mkv", LibraryID: "lib-1"},
&model.Media{Title: "动作", Genres: "Action", Path: "/b.mkv", LibraryID: "lib-1"},
)
got := listFiltered(t, db, MediaQueryFilter{IncludeNSFW: true, Genres: []string{"Science Fiction"}})
if !hasTitle(got, "科幻") {
t.Fatalf("result = %v, want 科幻 (multi-word genre must match after space stripping)", got)
}
if hasTitle(got, "动作") {
t.Fatalf("result = %v, must not contain 动作", got)
}
}
// UnwatchedOnly 缺省 userID 时必须忽略该条件,而不是返回空结果。
func TestFilterUnwatchedWithoutUserIsIgnored(t *testing.T) {
db := newMediaFilterTestDB(t)
seedFilterMedia(t, db, &model.Media{Title: "片", Path: "/a.mkv", LibraryID: "lib-1"})
got := listFiltered(t, db, MediaQueryFilter{IncludeNSFW: true, UnwatchedOnly: true})
if len(got) != 1 {
t.Fatalf("result = %v, want the row to be returned", got)
}
}
@@ -0,0 +1,61 @@
package repository
import (
"context"
"errors"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/truewhile/MeBox/internal/model"
)
// MediaProbeRepository 持久化 ffprobe 全量探测的结果缓存。
type MediaProbeRepository struct{ db *gorm.DB }
// Get 返回某媒体的探测缓存,未探测过时返回 (nil, nil)。
func (r *MediaProbeRepository) Get(ctx context.Context, mediaID string) (*model.MediaProbe, error) {
var row model.MediaProbe
err := r.db.WithContext(ctx).
Where("media_id = ?", mediaID).
First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &row, nil
}
// Upsert 写入一次成功的探测结果(含裁剪后的媒体信息)。
func (r *MediaProbeRepository) Upsert(ctx context.Context, row *model.MediaProbe) error {
onConflict := clause.OnConflict{
Columns: []clause.Column{{Name: "media_id"}},
DoUpdates: clause.AssignmentColumns([]string{
"signature", "source",
"container", "duration_sec", "bit_rate",
"width", "height", "video_codec", "audio_codec",
"video_streams", "audio_streams", "subtitle_streams", "chapter_count",
"payload", "probed_at", "last_error",
"deleted_at",
}),
}
return r.db.WithContext(ctx).Clauses(onConflict).Create(row).Error
}
// MarkFailure 记录一次失败的探测。它只更新「时间 + 错误信息」,刻意不碰
// payload 与其它字段:一次失败的重探不该把上一次成功拿到的媒体信息抹掉。
func (r *MediaProbeRepository) MarkFailure(ctx context.Context, mediaID, message string, probedAt time.Time) error {
row := model.MediaProbe{MediaID: mediaID, ProbedAt: probedAt, LastError: message}
onConflict := clause.OnConflict{
Columns: []clause.Column{{Name: "media_id"}},
DoUpdates: clause.Assignments(map[string]any{
"probed_at": probedAt,
"last_error": message,
"deleted_at": nil,
}),
}
return r.db.WithContext(ctx).Clauses(onConflict).Create(&row).Error
}
@@ -0,0 +1,105 @@
package repository
import (
"testing"
"time"
"github.com/truewhile/MeBox/internal/model"
)
func TestMediaProbeUpsertReplacesAndGetReturnsLatest(t *testing.T) {
repos := newSegmentTestRepos(t)
ctx := t.Context()
first := &model.MediaProbe{
MediaID: "m-1", Signature: "sig-1", Source: "local",
Container: "matroska,webm", DurationSec: 1451, ChapterCount: 2,
Payload: `{"container":"matroska,webm"}`, ProbedAt: time.Now(),
}
if err := repos.MediaProbe.Upsert(ctx, first); err != nil {
t.Fatalf("upsert #1: %v", err)
}
second := &model.MediaProbe{
MediaID: "m-1", Signature: "sig-2", Source: "strm",
Container: "mp4", DurationSec: 900, ChapterCount: 0,
Payload: `{"container":"mp4"}`, ProbedAt: time.Now(),
}
if err := repos.MediaProbe.Upsert(ctx, second); err != nil {
t.Fatalf("upsert #2: %v", err)
}
got, err := repos.MediaProbe.Get(ctx, "m-1")
if err != nil {
t.Fatal(err)
}
if got == nil {
t.Fatal("probe row missing")
}
if got.Container != "mp4" || got.DurationSec != 900 || got.Signature != "sig-2" || got.Payload != `{"container":"mp4"}` {
t.Fatalf("probe = %#v, want the second upsert to win", got)
}
// 重复探测不能累积重复行:media_id 是唯一索引。
var count int64
if err := repos.DB.Model(&model.MediaProbe{}).Where("media_id = ?", "m-1").Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("rows = %d, want 1", count)
}
}
func TestMediaProbeGetReturnsNilWhenMissing(t *testing.T) {
repos := newSegmentTestRepos(t)
got, err := repos.MediaProbe.Get(t.Context(), "nope")
if err != nil {
t.Fatalf("Get: %v", err)
}
if got != nil {
t.Fatalf("probe = %#v, want nil", got)
}
}
func TestMediaProbeMarkFailureKeepsPreviousPayload(t *testing.T) {
repos := newSegmentTestRepos(t)
ctx := t.Context()
if err := repos.MediaProbe.Upsert(ctx, &model.MediaProbe{
MediaID: "m-1", Container: "matroska,webm", DurationSec: 1451,
ChapterCount: 2, Payload: `{"container":"matroska,webm"}`, ProbedAt: time.Now(),
}); err != nil {
t.Fatal(err)
}
if err := repos.MediaProbe.MarkFailure(ctx, "m-1", "ffprobe full: exit status 1", time.Now()); err != nil {
t.Fatal(err)
}
got, err := repos.MediaProbe.Get(ctx, "m-1")
if err != nil {
t.Fatal(err)
}
if got == nil || got.LastError != "ffprobe full: exit status 1" {
t.Fatalf("probe = %#v, want the recorded failure", got)
}
// 一次失败的重探不该把上一次成功拿到的媒体信息抹掉。
if got.Container != "matroska,webm" || got.DurationSec != 1451 || got.Payload == "" || got.ChapterCount != 2 {
t.Fatalf("a failed re-probe wiped the previous summary: %#v", got)
}
}
func TestMediaProbeMarkFailureInsertsRowWhenAbsent(t *testing.T) {
repos := newSegmentTestRepos(t)
ctx := t.Context()
// 没有历史成功记录时,失败也必须落一行:否则冷却期没有时间戳,
// 每次播放都会重跑一次注定失败的探测。
if err := repos.MediaProbe.MarkFailure(ctx, "m-2", "boom", time.Now()); err != nil {
t.Fatal(err)
}
got, err := repos.MediaProbe.Get(ctx, "m-2")
if err != nil {
t.Fatal(err)
}
if got == nil || got.LastError != "boom" || got.ProbedAt.IsZero() {
t.Fatalf("probe = %#v, want a failure row with a timestamp", got)
}
}
+174
View File
@@ -44,6 +44,21 @@ type MediaQueryFilter struct {
AllowedLibraryIDs []string
HiddenLibraryIDs []string
SeriesID string
// LibraryID 是精确匹配的单个库过滤,用于库内场景(例如媒体库页筛选)。
// 它与 AllowedLibraryIDs 是「与」关系:可见性仍由后者兜底,避免越权。
LibraryID string
// Genres 是类型多选,之间为「或」。按整词匹配(见 genreMatchClause)。
Genres []string
// YearMin / YearMax 为 0 表示该端不限。
YearMin int
YearMax int
// RatingMin 为 0 表示不限。
RatingMin float64
// UnwatchedOnly 排除 UnwatchedUserID 已标记看完的条目。
// 「未观看」定义为「没有 completed=true 的记录」:看到一半的仍会出现,
// 与「继续观看」互补而不是重复。
UnwatchedOnly bool
UnwatchedUserID string
}
func applyMediaQueryFilter(q *gorm.DB, filter MediaQueryFilter) *gorm.DB {
@@ -56,12 +71,113 @@ func applyMediaQueryFilter(q *gorm.DB, filter MediaQueryFilter) *gorm.DB {
if len(filter.AllowedLibraryIDs) > 0 {
q = q.Where("library_id IN ?", filter.AllowedLibraryIDs)
}
if libraryID := strings.TrimSpace(filter.LibraryID); libraryID != "" {
q = q.Where("library_id = ?", libraryID)
}
if seriesID := strings.TrimSpace(filter.SeriesID); seriesID != "" {
q = q.Where("series_id = ?", seriesID)
}
if len(filter.Genres) > 0 {
q = q.Where(genreMatchClause(filter.Genres), genreMatchArgs(filter.Genres)...)
}
if filter.YearMin > 0 {
q = q.Where("year >= ?", filter.YearMin)
}
if filter.YearMax > 0 {
q = q.Where("year <= ?", filter.YearMax)
}
if filter.RatingMin > 0 {
q = q.Where("rating >= ?", filter.RatingMin)
}
if filter.UnwatchedOnly {
userID := strings.TrimSpace(filter.UnwatchedUserID)
// 没有用户上下文时忽略该条件:否则会把整个库筛成空,看起来像「坏了」。
if userID != "" {
q = q.Where(
"id NOT IN (SELECT media_id FROM playback_histories WHERE user_id = ? AND completed = ?)",
userID, true,
)
}
}
return q
}
// genreMatchClause 生成类型整词匹配条件。
//
// genres 列是逗号分隔字符串,直接 LIKE '%Action%' 会把 "ActionComedy" 也命中。
// 这里统一补上首尾逗号(并用空格容错)后再按 "%,Action,%" 匹配,实现整词语义;
// 该写法在 SQLite 与 PostgreSQL 上行为一致,因此不需要方言分支。
//
// 注意写法:参数本身带上首尾逗号,SQL 里只做一次 REPLACE 来保证列值两端也有
// 分隔符,避免 OR 链里重复拼接列表达式。
func genreMatchClause(genres []string) string {
clauses := make([]string, 0, len(genres))
for range genres {
clauses = append(clauses, "',' || REPLACE(REPLACE(TRIM(genres), ' ', ''), ',', ',') || ',' LIKE ?")
}
return "(" + strings.Join(clauses, " OR ") + ")"
}
// genreMatchArgs 生成与 genreMatchClause 对应的参数,形如 "%,Action,%"。
//
// 必须与 genreMatchClause 的列端处理完全对称:
// - TRIM → TrimSpace
// - REPLACE(…, ' ', '') → ReplaceAll(…, " ", "") ← 多词类型(如 "Science Fiction")
// - REPLACE(…, ',', ',') → ReplaceAll(…, ",", ",")
func genreMatchArgs(genres []string) []any {
args := make([]any, 0, len(genres))
for _, genre := range genres {
name := strings.ReplaceAll(strings.TrimSpace(genre), ",", ",")
name = strings.ReplaceAll(name, " ", "") // mirror REPLACE(…,' ','') in genreMatchClause
if name == "" {
name = "\x00" // 空类型不会命中任何行
}
args = append(args, "%,"+name+",%")
}
return args
}
// ListGenreValues 返回符合过滤条件的 media.genres 原始值(逗号分隔字符串)。
//
// 只取单列:类型聚合不需要整行 media,而一台大库的整行扫描会把海报 URL、
// 简介等大字段一起读进内存。切分与去重交给调用方,SQL 层保持方言无关。
func (r *MediaRepository) ListGenreValues(ctx context.Context, filter MediaQueryFilter) ([]string, error) {
var values []string
q := r.db.WithContext(ctx).
Model(&model.Media{}).
Where("genres IS NOT NULL AND genres <> ''")
q = applyMediaQueryFilter(q, filter)
if err := q.Pluck("genres", &values).Error; err != nil {
return nil, err
}
return values, nil
}
// YearRange 返回符合过滤条件的年份区间(两端都为 0 表示没有可用年份)。
// 供媒体库筛选面板生成年份上下限,避免前端硬编码或先取全量再自己算。
func (r *MediaRepository) YearRange(ctx context.Context, filter MediaQueryFilter) (int, int, error) {
var bounds struct {
MinYear *int
MaxYear *int
}
q := r.db.WithContext(ctx).
Model(&model.Media{}).
Where("year > 0").
Select("MIN(year) AS min_year, MAX(year) AS max_year")
q = applyMediaQueryFilter(q, filter)
if err := q.Scan(&bounds).Error; err != nil {
return 0, 0, err
}
min, max := 0, 0
if bounds.MinYear != nil {
min = *bounds.MinYear
}
if bounds.MaxYear != nil {
max = *bounds.MaxYear
}
return min, max, nil
}
func (r *MediaRepository) indexMediaBestEffort(ctx context.Context, media model.Media) {
backend, ok := r.searchBackend.(MediaSearchSyncBackend)
if !ok {
@@ -83,6 +199,64 @@ func (r *MediaRepository) FindByID(ctx context.Context, id string) (*model.Media
return &m, nil
}
// ExistsSiblingWithTMDbID reports whether another row of the same show carries
// the same tm_db_id as m.
//
// 它的用途是把「剧集级 id」和「单集自己的 id」区分开:一部剧的多集共用一个
// 剧集级 id,而单集各自的 id 不会重复。调用方据此决定能否把 Media.TMDbID
// 当作 Series.TMDbID 的替代品(见 MediaSegmentService.queryIDs)。
//
// 同一部剧的判定优先用 series_id;没有 series_id 的行(部分刮削路径不写它)
// 退回到「同一个库 + 同一个标题」。查询失败按「不共用」处理:宁可不查,
// 也不能拿一个可能是单集的 id 去查错片。
func (r *MediaRepository) ExistsSiblingWithTMDbID(ctx context.Context, m *model.Media) bool {
if r == nil || m == nil || m.TMDbID <= 0 || m.ID == "" {
return false
}
query := r.db.WithContext(ctx).Model(&model.Media{}).
Where("tm_db_id = ? AND id <> ?", m.TMDbID, m.ID)
if seriesID := strings.TrimSpace(m.SeriesID); seriesID != "" {
query = query.Where("series_id = ?", seriesID)
} else {
libraryID := strings.TrimSpace(m.LibraryID)
title := strings.TrimSpace(m.Title)
if libraryID == "" || title == "" {
return false
}
query = query.Where("library_id = ? AND title = ?", libraryID, title)
}
var count int64
if err := query.Limit(1).Count(&count).Error; err != nil {
return false
}
return count > 0
}
// ListSeasonSiblings returns other episodes in the same season as m.
// Prefers series_id; falls back to shared library+title+tm_db_id (anime scrape path).
func (r *MediaRepository) ListSeasonSiblings(ctx context.Context, m *model.Media) ([]model.Media, error) {
if r == nil || m == nil || m.SeasonNum <= 0 || m.ID == "" {
return nil, nil
}
query := r.db.WithContext(ctx).Model(&model.Media{}).
Where("season_num = ? AND id <> ?", m.SeasonNum, m.ID)
if seriesID := strings.TrimSpace(m.SeriesID); seriesID != "" {
query = query.Where("series_id = ?", seriesID)
} else {
libraryID := strings.TrimSpace(m.LibraryID)
title := strings.TrimSpace(m.Title)
if libraryID == "" || title == "" || m.TMDbID <= 0 {
return nil, nil
}
query = query.Where("library_id = ? AND title = ? AND tm_db_id = ?", libraryID, title, m.TMDbID)
}
var rows []model.Media
if err := query.Find(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
// ListByLibrary returns paginated media items for a library.
func (r *MediaRepository) ListByLibrary(ctx context.Context, libraryID string, offset, limit int) ([]model.Media, int64, error) {
return r.ListByLibraryFiltered(ctx, libraryID, offset, limit, MediaQueryFilter{IncludeNSFW: true})
@@ -332,6 +332,11 @@ func addMediaPlacementUpdates(updates map[string]any, existing, incoming model.M
if episodeChanged {
updates["episode_num"] = incoming.EpisodeNum
}
// 半集的小数部分独立于整数集号比较:S01E11.5 重命名/新增/删除时都要落库,
// 否则「11」与「11.5」会被当成同一集折叠。
if existing.EpisodeFraction != incoming.EpisodeFraction {
updates["episode_fraction"] = incoming.EpisodeFraction
}
if strings.TrimSpace(existing.ScrapeStatus) == "no_match" && incoming.ScrapeStatus != "matched" && (seasonChanged || episodeChanged) {
updates["scrape_status"] = "pending"
}
@@ -0,0 +1,129 @@
package repository
import (
"context"
"errors"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/truewhile/MeBox/internal/model"
)
// MediaSegmentRepository persists intro/recap/credits/preview ranges.
type MediaSegmentRepository struct{ db *gorm.DB }
// ListByMedia returns every stored segment for a media item, ordered by start.
func (r *MediaSegmentRepository) ListByMedia(ctx context.Context, mediaID string) ([]model.MediaSegment, error) {
rows := make([]model.MediaSegment, 0, 4)
err := r.db.WithContext(ctx).
Where("media_id = ?", mediaID).
Order("start_ms asc").
Find(&rows).Error
return rows, err
}
// ListByMediaSource returns the segments contributed by one source only, ordered
// by start. Used to read the ffprobe-extracted chapters independently of the
// community-database rows, so the player can pick between them.
func (r *MediaSegmentRepository) ListByMediaSource(ctx context.Context, mediaID, source string) ([]model.MediaSegment, error) {
rows := make([]model.MediaSegment, 0, 4)
err := r.db.WithContext(ctx).
Where("media_id = ? AND source = ?", mediaID, source).
Order("start_ms asc").
Find(&rows).Error
return rows, err
}
// ReplaceForMedia swaps the segments contributed by one source in a single
// transaction, so a provider refresh can never leave a half-updated set.
//
// Rows are hard-deleted rather than soft-deleted: the unique index on
// (media_id, kind, start_ms) would otherwise collide with the tombstoned rows
// on the next insert.
func (r *MediaSegmentRepository) ReplaceForMedia(ctx context.Context, mediaID, source string, rows []model.MediaSegment) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Unscoped().
Where("media_id = ? AND source = ?", mediaID, source).
Delete(&model.MediaSegment{}).Error; err != nil {
return err
}
if len(rows) == 0 {
return nil
}
return tx.Create(&rows).Error
})
}
// GetFetch returns the fetch ledger row for (media, source), or (nil, nil).
func (r *MediaSegmentRepository) GetFetch(ctx context.Context, mediaID, source string) (*model.MediaSegmentFetch, error) {
var row model.MediaSegmentFetch
err := r.db.WithContext(ctx).
Where("media_id = ? AND source = ?", mediaID, source).
First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &row, nil
}
// UpsertFetch records the outcome of a provider lookup. The `deleted_at: nil`
// assignment revives a previously deleted row instead of failing on the unique
// index, mirroring the playback history upsert.
func (r *MediaSegmentRepository) UpsertFetch(ctx context.Context, row *model.MediaSegmentFetch) error {
onConflict := clause.OnConflict{
Columns: []clause.Column{{Name: "media_id"}, {Name: "source"}},
DoUpdates: clause.Assignments(map[string]any{
"fetched_at": row.FetchedAt,
"found": row.Found,
"deleted_at": nil,
}),
}
return r.db.WithContext(ctx).Clauses(onConflict).Create(row).Error
}
// ListPrewarmCandidates returns media that should be refreshed from source:
// no ledger, expired miss (fetched_at < missBefore), or expired hit (fetched_at < hitBefore).
// Recently played items come first so hot titles recover coverage sooner.
func (r *MediaSegmentRepository) ListPrewarmCandidates(
ctx context.Context,
source string,
missBefore, hitBefore time.Time,
limit int,
) ([]model.Media, error) {
if r == nil || limit <= 0 {
return nil, nil
}
rows := make([]model.Media, 0, limit)
// 可查询:有 TMDb,且是剧集(有季集)或电影(无季集)。
err := r.db.WithContext(ctx).Raw(`
SELECT m.*
FROM media m
LEFT JOIN media_segment_fetches f
ON f.media_id = m.id AND f.source = ? AND f.deleted_at IS NULL
LEFT JOIN (
SELECT media_id, MAX(updated_at) AS last_played
FROM playback_histories
WHERE deleted_at IS NULL
GROUP BY media_id
) ph ON ph.media_id = m.id
WHERE m.deleted_at IS NULL
AND m.tm_db_id > 0
AND (
(m.season_num > 0 AND m.episode_num > 0)
OR (COALESCE(m.season_num, 0) = 0 AND COALESCE(m.episode_num, 0) = 0)
)
AND (
f.id IS NULL
OR (f.found = 0 AND f.fetched_at < ?)
OR (f.found = 1 AND f.fetched_at < ?)
)
ORDER BY ph.last_played DESC
LIMIT ?
`, source, missBefore, hitBefore, limit).Scan(&rows).Error
return rows, err
}
@@ -0,0 +1,207 @@
package repository
import (
"testing"
"time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/database"
"github.com/truewhile/MeBox/internal/model"
)
func newSegmentTestRepos(t *testing.T) *Container {
t.Helper()
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)
}
return New(db)
}
func TestReplaceForMediaIsIdempotentAcrossRefreshes(t *testing.T) {
repos := newSegmentTestRepos(t)
ctx := t.Context()
rows := []model.MediaSegment{
{MediaID: "m-1", Kind: model.SegmentKindIntro, StartMs: 228_664, EndMs: 246_143, Source: "theintrodb"},
{MediaID: "m-1", Kind: model.SegmentKindCredits, StartMs: 3_431_000, EndMs: 0, Source: "theintrodb"},
}
// 重复刷新不能因为 (media_id, kind, start_ms, source) 唯一索引而失败,也不能累积重复行。
for i := 0; i < 2; i++ {
if err := repos.MediaSegment.ReplaceForMedia(ctx, "m-1", "theintrodb", rows); err != nil {
t.Fatalf("replace #%d: %v", i+1, err)
}
}
got, err := repos.MediaSegment.ListByMedia(ctx, "m-1")
if err != nil {
t.Fatal(err)
}
if len(got) != 2 {
t.Fatalf("rows = %d, want 2 after two refreshes of the same source", len(got))
}
// 替换只影响同一来源:另一个来源的数据必须保留。
other := []model.MediaSegment{
{MediaID: "m-1", Kind: model.SegmentKindIntro, StartMs: 10, EndMs: 20, Source: "manual"},
}
if err := repos.MediaSegment.ReplaceForMedia(ctx, "m-1", "manual", other); err != nil {
t.Fatal(err)
}
got, err = repos.MediaSegment.ListByMedia(ctx, "m-1")
if err != nil {
t.Fatal(err)
}
if len(got) != 3 {
t.Fatalf("rows = %d, want 3 (2 theintrodb + 1 manual)", len(got))
}
if got[0].Source != "manual" || got[0].StartMs != 10 {
t.Fatalf("rows should be ordered by start_ms, got first = %#v", got[0])
}
}
func TestReplaceForMediaAllowsSameRangeFromDifferentSources(t *testing.T) {
repos := newSegmentTestRepos(t)
ctx := t.Context()
span := model.MediaSegment{MediaID: "m-1", Kind: model.SegmentKindIntro, StartMs: 1_000, EndMs: 2_000}
provider := span
provider.Source = "theintrodb"
if err := repos.MediaSegment.ReplaceForMedia(ctx, "m-1", "theintrodb", []model.MediaSegment{provider}); err != nil {
t.Fatal(err)
}
// 人工修正给出完全相同的区间:唯一索引含 source,两个来源必须能共存。
manual := span
manual.Source = "manual"
if err := repos.MediaSegment.ReplaceForMedia(ctx, "m-1", "manual", []model.MediaSegment{manual}); err != nil {
t.Fatalf("same range from another source: %v", err)
}
got, err := repos.MediaSegment.ListByMedia(ctx, "m-1")
if err != nil {
t.Fatal(err)
}
if len(got) != 2 {
t.Fatalf("rows = %d, want 2 (one per source)", len(got))
}
}
func TestReplaceForMediaClearsRowsWhenLookupReturnsNothing(t *testing.T) {
repos := newSegmentTestRepos(t)
ctx := t.Context()
rows := []model.MediaSegment{
{MediaID: "m-1", Kind: model.SegmentKindIntro, StartMs: 1_000, EndMs: 2_000, Source: "theintrodb"},
}
if err := repos.MediaSegment.ReplaceForMedia(ctx, "m-1", "theintrodb", rows); err != nil {
t.Fatal(err)
}
// 提供方后来把这段数据删掉了,本地必须跟着清空,否则会一直跳一个不存在的片头。
if err := repos.MediaSegment.ReplaceForMedia(ctx, "m-1", "theintrodb", nil); err != nil {
t.Fatal(err)
}
got, err := repos.MediaSegment.ListByMedia(ctx, "m-1")
if err != nil {
t.Fatal(err)
}
if len(got) != 0 {
t.Fatalf("rows = %d, want 0 after an empty refresh", len(got))
}
}
func TestUpsertFetchKeepsOneRowPerMediaAndSource(t *testing.T) {
repos := newSegmentTestRepos(t)
ctx := t.Context()
now := time.Now()
if err := repos.MediaSegment.UpsertFetch(ctx, &model.MediaSegmentFetch{
MediaID: "m-1", Source: "theintrodb", FetchedAt: now, Found: false,
}); err != nil {
t.Fatalf("first upsert: %v", err)
}
got, err := repos.MediaSegment.GetFetch(ctx, "m-1", "theintrodb")
if err != nil {
t.Fatal(err)
}
if got == nil || got.Found {
t.Fatalf("first lookup should be recorded as a miss, got %#v", got)
}
later := now.Add(time.Hour)
if err := repos.MediaSegment.UpsertFetch(ctx, &model.MediaSegmentFetch{
MediaID: "m-1", Source: "theintrodb", FetchedAt: later, Found: true,
}); err != nil {
t.Fatalf("second upsert: %v", err)
}
var count int64
if err := repos.DB.Model(&model.MediaSegmentFetch{}).
Where("media_id = ? AND source = ?", "m-1", "theintrodb").Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("fetch ledger rows = %d, want 1", count)
}
got, err = repos.MediaSegment.GetFetch(ctx, "m-1", "theintrodb")
if err != nil {
t.Fatal(err)
}
if got == nil || !got.Found {
t.Fatalf("ledger should be updated in place, got %#v", got)
}
}
func TestListSeasonSiblingsSharesLibraryTitleTMDb(t *testing.T) {
repos := newSegmentTestRepos(t)
ctx := t.Context()
eps := []*model.Media{
{Base: model.Base{ID: "a"}, LibraryID: "lib", Title: "Show", Path: "/a", SeasonNum: 1, EpisodeNum: 1, TMDbID: 99},
{Base: model.Base{ID: "b"}, LibraryID: "lib", Title: "Show", Path: "/b", SeasonNum: 1, EpisodeNum: 2, TMDbID: 99},
{Base: model.Base{ID: "c"}, LibraryID: "lib", Title: "Show", Path: "/c", SeasonNum: 2, EpisodeNum: 1, TMDbID: 99},
}
for _, ep := range eps {
if err := repos.DB.Create(ep).Error; err != nil {
t.Fatal(err)
}
}
got, err := repos.Media.ListSeasonSiblings(ctx, eps[0])
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].ID != "b" {
t.Fatalf("siblings = %#v, want only same-season ep b", got)
}
}
func TestListPrewarmCandidatesOrdersRecentPlaysFirst(t *testing.T) {
repos := newSegmentTestRepos(t)
ctx := t.Context()
now := time.Now()
for _, m := range []*model.Media{
{Base: model.Base{ID: "cold"}, Path: "/cold", TMDbID: 1},
{Base: model.Base{ID: "hot"}, Path: "/hot", TMDbID: 2},
} {
if err := repos.DB.Create(m).Error; err != nil {
t.Fatal(err)
}
}
if err := repos.DB.Create(&model.PlaybackHistory{
Base: model.Base{ID: "ph1"}, UserID: "u", MediaID: "hot",
}).Error; err != nil {
t.Fatal(err)
}
got, err := repos.MediaSegment.ListPrewarmCandidates(
ctx, "theintrodb", now.Add(-time.Hour), now.Add(-time.Hour), 10,
)
if err != nil {
t.Fatal(err)
}
if len(got) < 2 {
t.Fatalf("candidates = %#v, want both", got)
}
if got[0].ID != "hot" {
t.Fatalf("first = %s, want hot (recently played)", got[0].ID)
}
}
+4
View File
@@ -15,6 +15,8 @@ type Container struct {
Media *MediaRepository
Series *SeriesRepository
History *HistoryRepository
MediaSegment *MediaSegmentRepository
MediaProbe *MediaProbeRepository
Favorite *FavoriteRepository
Playlist *PlaylistRepository
Setting *SettingRepository
@@ -45,6 +47,8 @@ func New(db *gorm.DB) *Container {
Media: &MediaRepository{db: db},
Series: &SeriesRepository{db: db},
History: &HistoryRepository{db: db},
MediaSegment: &MediaSegmentRepository{db: db},
MediaProbe: &MediaProbeRepository{db: db},
Favorite: &FavoriteRepository{db: db},
Playlist: &PlaylistRepository{db: db},
Setting: &SettingRepository{db: db},
+305 -67
View File
@@ -4,7 +4,7 @@
package service
import (
"errors"
"encoding/hex"
"os"
"path/filepath"
"sort"
@@ -55,18 +55,287 @@ type PruneImageCacheResult struct {
RemainingBytes int64
}
// imageCacheFileEntry 是池内一个可被淘汰的缓存文件。
type imageCacheFileEntry struct {
path string
pool int
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).
// ImageCacheScope 是池内的一处文件范围。Recursive=false 时只处理该目录下的
// 直属文件(历史版本的图片缓存是平铺在 images/ 下的,升级后仍要能清理掉)。
type ImageCacheScope struct {
Root string
Recursive bool
// DropUnknownResizeVariants 只用于派生缩略图目录:直接删除文件名不符合当前
// 命名(<源图键>.<宽>x<高>q<质量>.img)的历史缩放缓存。它们在新的查找路径下
// 永远不会被命中,留着只会占用总量配额、把有用缓存挤出去。
DropUnknownResizeVariants bool
}
// ImageCachePool 描述一个图片缓存池:范围 + 自身配额 + 保留时长。
//
// In-flight temporary files (*.tmp) are skipped to avoid corrupting concurrent writes.
// Empty subdirectories left behind are best-effort removed.
// 分池的意义在于两类缓存的“可再生成本”完全不同:原图只是生成缩略图的原料,
// 丢了可以重新回源;派生缩略图是客户端热路径真正读取的成品,重新生成代价高。
// 因此原图配小配额、短保留,总量超限时也优先淘汰原图。
type ImageCachePool struct {
Name string
Scopes []ImageCacheScope
MaxBytes int64 // 0 = 不单独限制
MaxAge time.Duration // 0 = 不按时间淘汰
}
// imageFailMarkerMaxAge 是失败标记文件的保留时长:标记只用于观测/重试,
// 过期即视为噪音清掉(不会因为标记残留而阻止重新抓取)。
const imageFailMarkerMaxAge = 24 * time.Hour
// tempImageCacheFile 报告文件名是否是下载/缩放过程中的临时文件。它们正在被
// 并发写入,清理时必须跳过。
func tempImageCacheFile(name string) bool {
if strings.HasSuffix(name, ".tmp") {
return true
}
return strings.HasPrefix(name, "img-") && strings.Contains(name, ".tmp")
}
// sha256HexLength 是十六进制 sha256 摘要的长度。
const sha256HexLength = 64
// isCurrentResizeVariantName 报告文件名是否符合当前的缩放缓存命名
// (<源图键>.<宽>x<高>q<质量>.img)。
func isCurrentResizeVariantName(name string) bool {
if !strings.HasSuffix(name, ".img") {
return false
}
spec := strings.TrimSuffix(name, ".img")
key, _, found := strings.Cut(spec, ".")
if !found || len(key) != sha256HexLength {
return false
}
if _, err := hex.DecodeString(key); err != nil {
return false
}
_, _, ok := parseResizeVariantSuffix(name, key)
return ok
}
// collectImageCachePoolFiles 收集池内可淘汰的文件(跳过并发写入中的临时文件)。
// 失败标记单独返回,它们不计入容量,只按年龄清理。
func collectImageCachePoolFiles(pool ImageCachePool, poolIndex int, entries *[]imageCacheFileEntry, failMarkers *[]imageCacheFileEntry) {
visit := func(path string, info os.FileInfo, scope ImageCacheScope) {
if info.IsDir() {
return
}
name := info.Name()
if tempImageCacheFile(name) {
return
}
if scope.DropUnknownResizeVariants && strings.HasSuffix(name, ".img") && !isCurrentResizeVariantName(name) {
_ = os.Remove(path) // 旧版命名,永远不会命中,直接清掉
return
}
entry := imageCacheFileEntry{path: path, pool: poolIndex, size: info.Size(), modTime: info.ModTime()}
if strings.HasSuffix(name, ".fail") {
*failMarkers = append(*failMarkers, entry)
return
}
*entries = append(*entries, entry)
}
for _, scope := range pool.Scopes {
if strings.TrimSpace(scope.Root) == "" {
continue
}
if !scope.Recursive {
dir, err := os.Open(scope.Root) // #nosec G304 -- cache root from config.
if err != nil {
continue
}
names, err := dir.Readdir(-1)
_ = dir.Close()
if err != nil {
continue
}
for _, info := range names {
visit(filepath.Join(scope.Root, info.Name()), info, scope)
}
continue
}
_ = filepath.Walk(scope.Root, func(path string, info os.FileInfo, err error) error {
if err != nil || info == nil {
return nil // best-effort: one unreadable dir must not abort cleanup
}
visit(path, info, scope)
return nil
})
}
}
// removeImageCacheEntry 删除一个缓存文件,并顺带清掉它的失败标记。
func removeImageCacheEntry(entry imageCacheFileEntry, result *PruneImageCacheResult) {
if err := os.Remove(entry.path); err != nil {
return
}
result.DeletedFiles++
result.FreedBytes += entry.size
result.RemainingBytes -= entry.size
_ = os.Remove(entry.path + ".fail")
}
// PruneImageCachePools 按池清理图片缓存:
// 1. 删除超过池 MaxAge 的文件(原图按保留时长淘汰);
// 2. 池自身超过 MaxBytes 时按 mtime 淘汰到 80%(留水位,避免连续写入即触发);
// 3. 全部池合计超过 totalBytes 时,仍按“先原图、后派生”的顺序淘汰最旧文件,
// 使总量上限始终是硬保证,同时让客户端热路径的缩略图活得更久。
//
// 传入的池顺序即总量超限时的淘汰优先级(排在前面的先被淘汰)。
func PruneImageCachePools(pools []ImageCachePool, totalBytes int64) (PruneImageCacheResult, error) {
var result PruneImageCacheResult
if len(pools) == 0 {
return result, nil
}
var entries []imageCacheFileEntry
var failMarkers []imageCacheFileEntry
for i, pool := range pools {
collectImageCachePoolFiles(pool, i, &entries, &failMarkers)
}
result.TotalFilesBefore = len(entries)
for _, entry := range entries {
result.TotalBytesBefore += entry.size
}
result.RemainingBytes = result.TotalBytesBefore
// 过期的失败标记直接清掉:它们不计容量,只用于观测与重试。
cutoffMarkers := time.Now().Add(-imageFailMarkerMaxAge)
for _, marker := range failMarkers {
if marker.modTime.Before(cutoffMarkers) {
_ = os.Remove(marker.path)
}
}
now := time.Now()
deleted := make(map[string]bool, len(entries))
deleteEntry := func(entry imageCacheFileEntry) {
if deleted[entry.path] {
return
}
deleted[entry.path] = true
removeImageCacheEntry(entry, &result)
}
// 1. 按池保留时长淘汰(原图池)。
for i := range pools {
if pools[i].MaxAge <= 0 {
continue
}
cutoff := now.Add(-pools[i].MaxAge)
for _, entry := range entries {
if entry.pool == i && entry.modTime.Before(cutoff) {
deleteEntry(entry)
}
}
}
// 2. 池自身配额。
for i := range pools {
maxBytes := pools[i].MaxBytes
if maxBytes <= 0 {
continue
}
var poolBytes int64
for _, entry := range entries {
if entry.pool == i && !deleted[entry.path] {
poolBytes += entry.size
}
}
if poolBytes <= maxBytes {
continue
}
target := maxBytes * 80 / 100
for _, entry := range oldestFirst(entries, i, deleted) {
if poolBytes <= target {
break
}
deleteEntry(entry)
poolBytes -= entry.size
}
}
// 3. 总量硬上限:跨池按淘汰优先级(池顺序)再按 mtime 淘汰。
if totalBytes > 0 && result.RemainingBytes > totalBytes {
target := totalBytes * 80 / 100
for _, entry := range evictionOrder(entries, deleted) {
if result.RemainingBytes <= target {
break
}
deleteEntry(entry)
}
}
removeEmptyImageCacheDirs(pools)
return result, nil
}
// oldestFirst 返回指定池内未被删除的文件,按修改时间从旧到新。
func oldestFirst(entries []imageCacheFileEntry, pool int, deleted map[string]bool) []imageCacheFileEntry {
out := make([]imageCacheFileEntry, 0, len(entries))
for _, entry := range entries {
if entry.pool == pool && !deleted[entry.path] {
out = append(out, entry)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].modTime.Before(out[j].modTime) })
return out
}
// evictionOrder 返回总量超限时的淘汰顺序:先按池优先级(原图池在前),
// 池内再按修改时间从旧到新。
func evictionOrder(entries []imageCacheFileEntry, deleted map[string]bool) []imageCacheFileEntry {
out := make([]imageCacheFileEntry, 0, len(entries))
for _, entry := range entries {
if !deleted[entry.path] {
out = append(out, entry)
}
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].pool != out[j].pool {
return out[i].pool < out[j].pool
}
return out[i].modTime.Before(out[j].modTime)
})
return out
}
// removeEmptyImageCacheDirs 清理淘汰后留下的空子目录(不含池根目录本身)。
func removeEmptyImageCacheDirs(pools []ImageCachePool) {
seen := map[string]bool{}
for _, pool := range pools {
for _, scope := range pool.Scopes {
if !scope.Recursive || seen[scope.Root] {
continue
}
seen[scope.Root] = true
dirs := []string{}
_ = filepath.Walk(scope.Root, func(path string, info os.FileInfo, err error) error {
if err != nil || info == nil {
return nil
}
if info.IsDir() && path != scope.Root {
dirs = append(dirs, path)
}
return nil
})
for i := len(dirs) - 1; i >= 0; i-- {
_ = os.Remove(dirs[i])
}
}
}
}
// PruneImageCache 是单池版本的兼容入口:把整个图片目录当作一个池,超限时按
// mtime 淘汰到 80%。保留它是为了「立刻清理图片缓存」这类只关心总量的调用方。
func PruneImageCache(imagesDir string, maxSizeBytes int64) (PruneImageCacheResult, error) {
var result PruneImageCacheResult
if imagesDir == "" || maxSizeBytes <= 0 {
@@ -75,66 +344,35 @@ func PruneImageCache(imagesDir string, maxSizeBytes int64) (PruneImageCacheResul
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
pool := ImageCachePool{
Name: "images",
Scopes: []ImageCacheScope{{Root: imagesDir, Recursive: true}},
MaxBytes: maxSizeBytes,
}
// 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
return PruneImageCachePools([]ImageCachePool{pool}, maxSizeBytes)
}
// ImageCachePools 按配置组装图片缓存的两个清理池。
//
// 第一个池是原图:历史版本平铺在 images/ 下的旧缓存也归入此池,升级后会被
// 逐步淘汰;第二个池是派生成品(本地缩放结果 + 挂载 Emby 按尺寸返回的成品)。
// 池顺序即总量超限时的淘汰优先级。
func ImageCachePools(imagesDir string, originalsMaxBytes int64, originalsMaxAge time.Duration) []ImageCachePool {
originals := ImageCachePool{
Name: "originals",
MaxBytes: originalsMaxBytes,
MaxAge: originalsMaxAge,
Scopes: []ImageCacheScope{
{Root: imagesDir, Recursive: false}, // 旧版平铺布局
{Root: filepath.Join(imagesDir, imageOriginalCacheSubdir), Recursive: true},
},
}
derived := ImageCachePool{
Name: "derived",
Scopes: []ImageCacheScope{
{Root: filepath.Join(imagesDir, imageResizeCacheSubdir), Recursive: true, DropUnknownResizeVariants: true},
{Root: filepath.Join(imagesDir, imageRenditionCacheSubdir), Recursive: true},
},
}
return []ImageCachePool{originals, derived}
}
@@ -0,0 +1,223 @@
package service
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// writePoolFile ?????????????????????????
func writePoolFile(t *testing.T, path string, size int, modTime time.Time) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
t.Fatalf("mkdir %s: %v", filepath.Dir(path), err)
}
if err := os.WriteFile(path, make([]byte, size), 0o600); err != nil {
t.Fatalf("write %s: %v", path, err)
}
if !modTime.IsZero() {
if err := os.Chtimes(path, modTime, modTime); err != nil {
t.Fatalf("chtimes %s: %v", path, err)
}
}
}
func mustNotExist(t *testing.T, path string) {
t.Helper()
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("expected %s to be deleted, got err=%v", path, err)
}
}
func mustExist(t *testing.T, path string) {
t.Helper()
if _, err := os.Stat(path); err != nil {
t.Fatalf("expected %s to survive, got err=%v", path, err)
}
}
// currentVariantPath ????????????????.<??x<??q<??>.img??
// ??????????????????????????
func currentVariantPath(dir, keyChar string, width, height, quality int) string {
key := strings.Repeat(keyChar, sha256HexLength)
return filepath.Join(dir, fmt.Sprintf("%s.%dx%dq%d.img", key, width, height, quality))
}
// TestPruneImageCachePoolsOriginalTTL ????????????????????
// ?? TTL ??????????????????????
func TestPruneImageCachePoolsOriginalTTL(t *testing.T) {
imagesDir := t.TempDir()
now := time.Now()
stale := filepath.Join(imagesDir, imageOriginalCacheSubdir, "stale-original")
fresh := filepath.Join(imagesDir, imageOriginalCacheSubdir, "fresh-original")
thumb := currentVariantPath(filepath.Join(imagesDir, imageResizeCacheSubdir), "b", 480, 600, 80)
writePoolFile(t, stale, 400, now.Add(-8*24*time.Hour))
writePoolFile(t, fresh, 400, now.Add(-1*time.Hour))
// ??????????TTL ????
writePoolFile(t, thumb, 400, now.Add(-8*24*time.Hour))
pools := ImageCachePools(imagesDir, 0, 7*24*time.Hour)
res, err := PruneImageCachePools(pools, 100*1024*1024)
if err != nil {
t.Fatalf("prune failed: %v", err)
}
if res.DeletedFiles != 1 {
t.Fatalf("deleted files = %d, want 1", res.DeletedFiles)
}
mustNotExist(t, stale)
mustExist(t, fresh)
mustExist(t, thumb)
}
// TestPruneImageCachePoolsEvictsOriginalsBeforeDerived ????????????
// ????????????????????
func TestPruneImageCachePoolsEvictsOriginalsBeforeDerived(t *testing.T) {
imagesDir := t.TempDir()
now := time.Now()
original := filepath.Join(imagesDir, imageOriginalCacheSubdir, "original")
rendition := filepath.Join(imagesDir, imageRenditionCacheSubdir, "rendition")
thumb := currentVariantPath(filepath.Join(imagesDir, imageResizeCacheSubdir), "c", 480, 600, 80)
// ?????????????????
writePoolFile(t, original, 1024*1024, now.Add(-72*time.Hour))
writePoolFile(t, rendition, 1024*1024, now.Add(-2*time.Hour))
writePoolFile(t, thumb, 1024*1024, now.Add(-1*time.Hour))
pools := ImageCachePools(imagesDir, 0, 0)
// ?? 3MB????2.4MB -> ????? 1.92MB??
res, err := PruneImageCachePools(pools, 2*1024*1024+400*1024)
if err != nil {
t.Fatalf("prune failed: %v", err)
}
if res.DeletedFiles != 2 {
t.Fatalf("deleted files = %d, want 2 (original + oldest derived)", res.DeletedFiles)
}
mustNotExist(t, original)
mustNotExist(t, rendition)
mustExist(t, thumb)
if res.RemainingBytes != 1024*1024 {
t.Fatalf("remaining bytes = %d, want %d", res.RemainingBytes, 1024*1024)
}
}
// TestPruneImageCachePoolsOriginalQuotaKeepsDerived ????????????
// ??????????????
func TestPruneImageCachePoolsOriginalQuotaKeepsDerived(t *testing.T) {
imagesDir := t.TempDir()
now := time.Now()
oldOriginal := filepath.Join(imagesDir, imageOriginalCacheSubdir, "old")
newOriginal := filepath.Join(imagesDir, imageOriginalCacheSubdir, "new")
thumb := currentVariantPath(filepath.Join(imagesDir, imageResizeCacheSubdir), "c", 480, 600, 80)
writePoolFile(t, oldOriginal, 800, now.Add(-3*time.Hour))
writePoolFile(t, newOriginal, 800, now.Add(-1*time.Hour))
writePoolFile(t, thumb, 800, now.Add(-2*time.Hour))
// ???? 1000 ??????????1600 -> ????800????? 1 ????
pools := ImageCachePools(imagesDir, 1000, 0)
res, err := PruneImageCachePools(pools, 0)
if err != nil {
t.Fatalf("prune failed: %v", err)
}
if res.DeletedFiles != 1 {
t.Fatalf("deleted files = %d, want 1", res.DeletedFiles)
}
mustNotExist(t, oldOriginal)
mustExist(t, newOriginal)
mustExist(t, thumb)
}
// TestPruneImageCachePoolsCleansFailMarkers ????????????????
// ????????????????
func TestPruneImageCachePoolsCleansFailMarkers(t *testing.T) {
imagesDir := t.TempDir()
now := time.Now()
entry := filepath.Join(imagesDir, imageOriginalCacheSubdir, "entry")
entryMarker := entry + ".fail"
orphanMarker := filepath.Join(imagesDir, imageOriginalCacheSubdir, "orphan.fail")
freshMarker := filepath.Join(imagesDir, imageOriginalCacheSubdir, "fresh.fail")
writePoolFile(t, entry, 900, now.Add(-10*time.Hour))
writePoolFile(t, entryMarker, 35, now.Add(-10*time.Hour))
writePoolFile(t, orphanMarker, 35, now.Add(-48*time.Hour))
writePoolFile(t, freshMarker, 35, now.Add(-1*time.Minute))
// ???? 500????900 ?????????????????
pools := ImageCachePools(imagesDir, 500, 0)
res, err := PruneImageCachePools(pools, 0)
if err != nil {
t.Fatalf("prune failed: %v", err)
}
if res.DeletedFiles != 1 {
t.Fatalf("deleted files = %d, want 1", res.DeletedFiles)
}
mustNotExist(t, entry)
mustNotExist(t, entryMarker)
mustNotExist(t, orphanMarker)
mustExist(t, freshMarker)
}
// TestPruneImageCachePoolsSkipsTemporaryFiles ????????????????
// ?????????????????
func TestPruneImageCachePoolsSkipsTemporaryFiles(t *testing.T) {
imagesDir := t.TempDir()
tmp := filepath.Join(imagesDir, imageOriginalCacheSubdir, "img-1234.tmp")
writePoolFile(t, tmp, 4096, time.Now().Add(-48*time.Hour))
pools := ImageCachePools(imagesDir, 1, 0)
if _, err := PruneImageCachePools(pools, 1); err != nil {
t.Fatalf("prune failed: %v", err)
}
mustExist(t, tmp)
}
// TestPruneImageCachePoolsDropsLegacyResizeVariants ????????.img????
// ?????????????????????????????????????
func TestPruneImageCachePoolsDropsLegacyResizeVariants(t *testing.T) {
imagesDir := t.TempDir()
resized := filepath.Join(imagesDir, imageResizeCacheSubdir)
legacy := filepath.Join(resized, "4f2a1b"+strings.Repeat("0", 58)+".img")
current := filepath.Join(resized, strings.Repeat("a", 64)+".480x600q80.img")
other := filepath.Join(resized, "not-a-hash.480x600q80.img")
writePoolFile(t, legacy, 400, time.Now())
writePoolFile(t, current, 400, time.Now())
writePoolFile(t, other, 400, time.Now())
pools := ImageCachePools(imagesDir, 0, 0)
res, err := PruneImageCachePools(pools, 0)
if err != nil {
t.Fatalf("prune failed: %v", err)
}
mustNotExist(t, legacy)
mustNotExist(t, other)
mustExist(t, current)
if res.TotalFilesBefore != 1 {
t.Fatalf("counted files = %d, want only the current-format variant", res.TotalFilesBefore)
}
}
// TestPruneImageCachePoolsCountsLegacyFlatLayout ??????images/ ????
// ?????????????????
func TestPruneImageCachePoolsCountsLegacyFlatLayout(t *testing.T) {
imagesDir := t.TempDir()
legacy := filepath.Join(imagesDir, "legacy-flat-cache")
thumb := currentVariantPath(filepath.Join(imagesDir, imageResizeCacheSubdir), "c", 480, 600, 80)
writePoolFile(t, legacy, 900, time.Now().Add(-30*24*time.Hour))
writePoolFile(t, thumb, 900, time.Now().Add(-30*24*time.Hour))
pools := ImageCachePools(imagesDir, 0, 24*time.Hour)
res, err := PruneImageCachePools(pools, 0)
if err != nil {
t.Fatalf("prune failed: %v", err)
}
if res.DeletedFiles != 1 {
t.Fatalf("deleted files = %d, want 1", res.DeletedFiles)
}
mustNotExist(t, legacy)
mustExist(t, thumb)
}
+2 -2
View File
@@ -148,8 +148,8 @@ func TestSchedulerJobCleanImageCache(t *testing.T) {
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
scheduler.SetImageCachePolicyProvider(func() ImageCachePolicy {
return ImageCachePolicy{TotalBytes: 1 * 1024 * 1024}
})
if err := scheduler.jobCleanImageCache(context.Background()); err != nil {
+21 -1
View File
@@ -58,12 +58,32 @@ type Cloud115PlaybackService struct {
repo *repository.Container
strm *StrmService
// transcoder 用于判断本地 HLS 档位此刻是否真的可用;未注入时按"不可用"处理,
// 避免向播放器推荐必然 500 的转码档位。
transcoder *TranscoderService
mu sync.Mutex
pushState map[string]cloud115PushAttempt
proxy *Cloud115HLSProxy
}
// SetTranscoder 注入转码服务,用于标注本地 HLS 档位的可用性。
func (s *Cloud115PlaybackService) SetTranscoder(transcoder *TranscoderService) *Cloud115PlaybackService {
if s != nil {
s.transcoder = transcoder
}
return s
}
// localTranscodeAvailable 报告本地 HLS 此刻是否可用。
func (s *Cloud115PlaybackService) localTranscodeAvailable() bool {
if s == nil || s.transcoder == nil {
return false
}
return s.transcoder.Available()
}
func NewCloud115PlaybackService(cfg *config.Config, log *zap.Logger, repo *repository.Container, strm *StrmService) *Cloud115PlaybackService {
svc := &Cloud115PlaybackService{
cfg: cfg,
@@ -101,7 +121,7 @@ func (s *Cloud115PlaybackService) PlaybackInfo(ctx context.Context, mediaID stri
}
provider := MediaPlaybackProvider(m)
localQualities := LocalQualityOptions(m)
localQualities := LocalQualityOptions(m, s.localTranscodeAvailable())
localDefault := DefaultLocalHLSQualityID(m)
info := &PlaybackInfo{
MediaID: m.ID,
+15 -1
View File
@@ -63,6 +63,8 @@ type DanmakuRenderConfig struct {
FontSize string `json:"font_size"`
Area string `json:"area"`
Volume float64 `json:"volume"`
// Vr360GuideSeen 表示当前用户是否已经看过 VR 全景播放的首次操作说明。
Vr360GuideSeen bool `json:"vr360_guide_seen"`
// MergeSources 是当前用户的弹幕合并偏好(按用户存储)。
MergeSources bool `json:"merge_sources"`
// AppKeyConfigured 只表明用户是否保存了应用密钥,不回传密钥明文。
@@ -89,6 +91,8 @@ type DanmakuSettingsPatch struct {
Area *float64 `json:"area"`
MergeSources *bool `json:"merge_sources"`
Volume *float64 `json:"volume"`
// Vr360GuideSeen 标记 VR 全景播放的首次操作说明是否已经看过。
Vr360GuideSeen *bool `json:"vr360_guide_seen"`
}
type danmakuUserContextKey struct{}
@@ -399,7 +403,13 @@ func (s *DanmakuService) findUser(ctx context.Context, userID string) (*model.Us
func danmakuConfigFromUser(user *model.User) DanmakuRenderConfig {
if user == nil {
return DanmakuRenderConfig{Enabled: true, Opacity: "1", FontSize: "24", Area: "1", Volume: 1}
return DanmakuRenderConfig{
Enabled: true,
Opacity: "1",
FontSize: "24",
Area: "1",
Volume: 1,
}
}
opacity := user.DanmakuOpacity
if opacity < 0.1 || opacity > 1 {
@@ -425,6 +435,7 @@ func danmakuConfigFromUser(user *model.User) DanmakuRenderConfig {
FontSize: strconv.Itoa(fontSize),
Area: strconv.FormatFloat(area, 'f', -1, 64),
Volume: volume,
Vr360GuideSeen: user.PlayerVr360GuideSeen,
MergeSources: user.DanmakuMergeSources,
AppKeyConfigured: strings.TrimSpace(user.DanmakuAppKey) != "",
}
@@ -495,6 +506,9 @@ func (s *DanmakuService) UpdateUserSettings(ctx context.Context, userID string,
}
updates["player_volume"] = *patch.Volume
}
if patch.Vr360GuideSeen != nil {
updates["player_vr360_guide_seen"] = *patch.Vr360GuideSeen
}
if len(updates) == 0 {
return DanmakuRenderConfig{}, ErrNoDanmakuSettings
}
+13 -3
View File
@@ -21,14 +21,24 @@ func (s *DeviceService) KickDevice(ctx context.Context, userID, deviceID string)
return fmt.Errorf("device not found")
}
if fp := strings.TrimSpace(d.Fingerprint); fp != "" {
return s.repo.UserDevice.SetKickedByFingerprint(ctx, userID, fp, true)
err = s.repo.UserDevice.SetKickedByFingerprint(ctx, userID, fp, true)
} else {
err = s.repo.UserDevice.SetKicked(ctx, d.ID, true)
}
return s.repo.UserDevice.SetKicked(ctx, d.ID, true)
if err != nil {
return err
}
s.notify(ctx, userID, fmt.Sprintf("🔌 设备已下线:<b>%s</b>\n该终端需要重新登录后才能继续使用。", deviceLabel(d.DeviceName, d.Client)))
return nil
}
// KickAllDevices marks all devices for a user as kicked.
func (s *DeviceService) KickAllDevices(ctx context.Context, userID string) error {
return s.repo.UserDevice.SetKickedByUser(ctx, userID, true)
if err := s.repo.UserDevice.SetKickedByUser(ctx, userID, true); err != nil {
return err
}
s.notify(ctx, userID, "🔌 你名下的全部设备已下线,需要重新登录后才能继续使用。")
return nil
}
// ListDevices returns the device sessions for a user.
+155
View File
@@ -0,0 +1,155 @@
package service
import (
"context"
"strings"
"testing"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
func newDeviceServiceForTest(t *testing.T) (*DeviceService, *repository.Container, string) {
t.Helper()
repos := repository.New(newServiceTestDB(t))
const userID = "user-1"
if err := repos.User.Create(context.Background(), &model.User{
Base: model.Base{ID: userID},
Username: "tester",
PasswordHash: "x",
Role: "user",
IsActive: true,
}); err != nil {
t.Fatal(err)
}
svc := NewDeviceService(zap.NewNop(), repos)
svc.SetSessionTracker(NewSessionTrackerService(zap.NewNop()))
return svc, repos, userID
}
// 新终端首次登录必须通知用户,否则「谁在用我的账号」永远无从察觉。
func TestRecordLoginNotifiesOnNewDevice(t *testing.T) {
svc, _, userID := newDeviceServiceForTest(t)
type call struct{ userID, text string }
var calls []call
svc.SetNotifier(func(_ context.Context, uid, text string) {
calls = append(calls, call{uid, text})
})
svc.RecordLogin(context.Background(), userID, "dev-1", "Phone", "Infuse", "1.2.3.4")
if len(calls) != 1 {
t.Fatalf("notifier calls = %d, want 1", len(calls))
}
if calls[0].userID != userID {
t.Fatalf("notifier user = %q, want %q", calls[0].userID, userID)
}
if !strings.Contains(calls[0].text, "新设备") {
t.Fatalf("notifier text = %q, want it to mention 新设备", calls[0].text)
}
}
// 已知终端重复登录不应刷屏:只在首次建档时通知。
func TestRecordLoginDoesNotNotifyOnKnownDevice(t *testing.T) {
svc, _, userID := newDeviceServiceForTest(t)
var count int
svc.SetNotifier(func(context.Context, string, string) { count++ })
svc.RecordLogin(context.Background(), userID, "dev-1", "Phone", "Infuse", "1.2.3.4")
svc.RecordLogin(context.Background(), userID, "dev-1", "Phone", "Infuse", "1.2.3.4")
if count != 1 {
t.Fatalf("notifier calls = %d, want exactly 1", count)
}
}
// 一键踢下线后必须告知用户,否则只会表现为「播放莫名失败」。
func TestKickDeviceNotifiesUser(t *testing.T) {
svc, _, userID := newDeviceServiceForTest(t)
svc.RecordLogin(context.Background(), userID, "dev-1", "Phone", "Infuse", "1.2.3.4")
var kickText string
svc.SetNotifier(func(_ context.Context, _, text string) { kickText = text })
if err := svc.KickAllDevices(context.Background(), userID); err != nil {
t.Fatal(err)
}
if kickText == "" {
t.Fatal("expected a notification after kicking devices")
}
if !strings.Contains(kickText, "已下线") && !strings.Contains(kickText, "踢") {
t.Fatalf("kick notification text = %q, want it to describe the kick", kickText)
}
}
// 未接线 notifier 时(例如测试环境或 Bot 未配置),所有路径必须保持可用。
func TestDeviceServiceWorksWithoutNotifier(t *testing.T) {
svc, _, userID := newDeviceServiceForTest(t)
svc.RecordLogin(context.Background(), userID, "dev-1", "Phone", "Infuse", "1.2.3.4")
if err := svc.KickAllDevices(context.Background(), userID); err != nil {
t.Fatal(err)
}
}
// 设备指纹警告除通知用户外,管理员也必须同步收到告警。
func TestFingerprintWarnNotifiesAdmin(t *testing.T) {
svc, repos, userID := newDeviceServiceForTest(t)
// 启用防共享策略
if err := repos.Setting.Set(context.Background(), SettingAntiShareEnabled, "true"); err != nil {
t.Fatal(err)
}
type call struct{ text string }
var adminCalls []call
svc.SetAdminNotifier(func(_ context.Context, text string) {
adminCalls = append(adminCalls, call{text})
})
svc.SetNotifier(func(context.Context, string, string) {}) // 用户通知静默接收
// 第一次登录注册设备
svc.RecordLogin(context.Background(), userID, "dev-1", "Phone-A", "Infuse", "1.2.3.4")
// 同设备 ID 换设备名 → 触发指纹变更警告
svc.RecordLogin(context.Background(), userID, "dev-1", "Phone-B", "Infuse", "1.2.3.4")
if len(adminCalls) == 0 {
t.Fatal("admin must be notified on fingerprint warn")
}
}
// 账号因设备策略被禁用时,管理员也必须收到告警。
func TestPolicyDisableNotifiesAdmin(t *testing.T) {
svc, repos, userID := newDeviceServiceForTest(t)
// 启用防共享策略,设置最大并发客户端为 1
for _, kv := range [][2]string{
{SettingAntiShareEnabled, "true"},
{SettingMaxLoggedClients, "1"},
{SettingClientActiveDays, "30"},
} {
if err := repos.Setting.Set(context.Background(), kv[0], kv[1]); err != nil {
t.Fatal(err)
}
}
var adminCalls []string
svc.SetAdminNotifier(func(_ context.Context, text string) {
adminCalls = append(adminCalls, text)
})
svc.SetNotifier(func(context.Context, string, string) {})
// 两台不同设备登录,超出上限 → 触发禁用
svc.RecordLogin(context.Background(), userID, "dev-1", "Phone", "Infuse", "1.2.3.4")
svc.RecordLogin(context.Background(), userID, "dev-2", "TV", "Emby", "1.2.3.5")
if len(adminCalls) == 0 {
t.Fatal("admin must be notified when account is disabled by policy")
}
if !strings.Contains(adminCalls[0], "禁用") {
t.Fatalf("admin notification = %q, want it to mention 禁用", adminCalls[0])
}
}
+26 -2
View File
@@ -5,6 +5,7 @@ import (
"crypto/sha256"
"encoding/hex"
"fmt"
"html"
"strings"
"time"
@@ -34,6 +35,10 @@ type DeviceService struct {
// notifyUser sends a Telegram message to the local user (resolved to their
// Telegram binding). Wired by the bot service; nil disables notifications.
notifyUser func(ctx context.Context, userID, text string)
// notifyAdmin sends a Telegram message to all admin accounts.
// Wired by the bot service; nil disables notifications.
notifyAdmin func(ctx context.Context, text string)
}
// NewDeviceService constructs a DeviceService.
@@ -46,6 +51,11 @@ func (s *DeviceService) SetNotifier(fn func(ctx context.Context, userID, text st
s.notifyUser = fn
}
// SetAdminNotifier wires the admin-broadcast Telegram notification callback.
func (s *DeviceService) SetAdminNotifier(fn func(ctx context.Context, text string)) {
s.notifyAdmin = fn
}
func (s *DeviceService) SetSessionTracker(tracker *SessionTrackerService) {
s.sessions = tracker
}
@@ -102,6 +112,8 @@ func (s *DeviceService) RecordLogin(ctx context.Context, userID, deviceID, devic
FirstSeenAt: now,
LastSeenAt: now,
})
// 新终端首次登录才通知:已知设备重复登录不刷屏。
s.notify(ctx, userID, fmt.Sprintf("🔔 新设备登录:<b>%s</b>\n如果这不是你本人,请到「个人资料 → 我的设备」踢下线并修改密码。", deviceLabel(deviceName, client)))
} else {
if existing.Fingerprint != "" && existing.Fingerprint != fp {
mismatch = true
@@ -236,7 +248,10 @@ func (s *DeviceService) registerFingerprintWarning(ctx context.Context, userID,
"last_share_warn_at": &now,
})
left := cfg.WarnThreshold + 1 - warnings
s.notify(ctx, userID, fmt.Sprintf("⚠️ 账号 <b>%s</b> 触发设备指纹警告:%s\n这是第 <b>%d</b> 次警告,再异常 <b>%d</b> 次将禁用账号。请使用 Bot 的「我的设备」踢下线异常设备。", u.Username, reason, warnings, left))
warnText := fmt.Sprintf("⚠️ 账号 <b>%s</b> 触发设备指纹警告:%s\n这是第 <b>%d</b> 次警告,再异常 <b>%d</b> 次将禁用账号。请到「个人资料 → 我的设备」踢下线异常设备。",
html.EscapeString(u.Username), html.EscapeString(reason), warnings, left)
s.notify(ctx, userID, warnText)
s.notifyAdminMsg(ctx, warnText)
s.log.Info("anti-share: warning issued", zap.String("user", u.Username), zap.Int("warnings", warnings), zap.String("reason", reason))
}
@@ -255,7 +270,10 @@ func (s *DeviceService) disableForPolicy(ctx context.Context, userID, reason str
"last_share_warn_at": &now,
})
_ = s.repo.UserDevice.SetKickedByUser(ctx, userID, true)
s.notify(ctx, userID, fmt.Sprintf("⛔️ 账号 <b>%s</b> 因触发设备规则已被禁用:%s\n请联系管理员解除禁用,或通过「我的设备」踢下线多余设备后再申请恢复。", u.Username, reason))
disableText := fmt.Sprintf("⛔️ 账号 <b>%s</b> 因触发设备规则已被禁用:%s\n请联系管理员解除禁用,或通过「个人资料 → 我的设备」踢下线多余设备后再申请恢复。",
html.EscapeString(u.Username), html.EscapeString(reason))
s.notify(ctx, userID, disableText)
s.notifyAdminMsg(ctx, disableText)
s.log.Warn("device policy: disabled account", zap.String("user", u.Username), zap.String("reason", reason))
}
@@ -276,6 +294,12 @@ func (s *DeviceService) notify(ctx context.Context, userID, text string) {
}
}
func (s *DeviceService) notifyAdminMsg(ctx context.Context, text string) {
if s.notifyAdmin != nil {
s.notifyAdmin(ctx, text)
}
}
func deviceLabel(name, client string) string {
name = strings.TrimSpace(name)
client = strings.TrimSpace(client)
+9 -1
View File
@@ -147,10 +147,18 @@ func (e *EmbyService) ImageInfos(ctx context.Context, id string) []map[string]an
continue
}
seen[raw] = true
// ImageTag 让客户端判断自己缓存的图片是否还有效:远程条目的真实
// ImageTags 会随远端换图变化,拿不到时才退化为条目 ID(恒定值)。
tag := id
if e.remote != nil {
if remoteTag := e.remote.RemoteImageTagOfEncodedID(ctx, id, imageType); remoteTag != "" {
tag = remoteTag
}
}
out = append(out, map[string]any{
"ImageType": imageType,
"ImageIndex": 0,
"ImageTag": id,
"ImageTag": tag,
})
}
return out
+32
View File
@@ -76,6 +76,10 @@ type EmbyService struct {
adult *AdultProvider
personImageMu sync.RWMutex
personImages map[string]string
// discovery 提供 NextUp / Similar / Genres 的候选集。它只选候选,
// DTO 形状仍由本服务统一产出,避免同一部剧在不同接口上长得不一样。
discovery *MediaDiscoveryService
}
// NewEmbyService is the constructor.
@@ -91,6 +95,26 @@ func (e *EmbyService) SetEmbyRemote(remote *EmbyRemoteService) *EmbyService {
return e
}
// SetDiscovery 注入发现类查询服务(NextUp / Similar / Genres)。
func (e *EmbyService) SetDiscovery(discovery *MediaDiscoveryService) *EmbyService {
if e != nil {
e.discovery = discovery
}
return e
}
// discoveryService 返回发现服务;未注入时按需构造,保证 Emby 接口在任何
// 组装顺序下都不会因为缺少注入而返回空结果。
func (e *EmbyService) discoveryService() *MediaDiscoveryService {
if e == nil {
return nil
}
if e.discovery == nil {
e.discovery = NewMediaDiscoveryService(e.log, e.repo)
}
return e.discovery
}
// SetTMDbProvider wires the TMDb client used for detail-time cast/crew lookup.
func (e *EmbyService) SetTMDbProvider(tmdb *TMDbProvider) *EmbyService {
if e != nil {
@@ -141,6 +165,10 @@ type ItemsParams struct {
SortOrder string
Limit int
StartIndex int
// SeasonIndex 对应客户端的 Season / SeasonIndex 查询参数(季序号,0 为特别篇)。
// nil 表示不按季过滤;非 nil 时只返回该季的剧集。客户端普遍用季序号而不是
// 虚拟季 ID 请求剧集,缺了它 /Shows/{id}/Episodes?Season=N 会返回整部剧。
SeasonIndex *int
}
const (
@@ -205,6 +233,10 @@ func (e *EmbyService) Items(ctx context.Context, p ItemsParams) (map[string]any,
if containsEmbyFilter(p.Filters, "IsFavorite") {
return e.favoriteItems(ctx, p)
}
// 继续观看必须走 MeBox 本地 PlaybackHistory,不能转发到远程共用账号。
if containsEmbyFilter(p.Filters, "IsResumable") {
return e.resumableItems(ctx, p)
}
mountID, _, _ := DecodeEmbyRemoteID(p.ParentID)
mount, acct, _ := e.remote.ResolveMount(ctx, mountID)
if mount == nil || acct == nil {
+132
View File
@@ -1,6 +1,8 @@
package service
import (
"context"
"strings"
"testing"
"time"
@@ -65,3 +67,133 @@ func TestEmbyLatestItemsOrderByReleaseDate(t *testing.T) {
t.Fatalf("latest item should expose PremiereDate for Emby clients: %#v", items[0])
}
}
// SimilarItems with a real series_id (not a media table row ID) must return
// results instead of an empty list. Before bug-2 fix, Media.FindByID returned
// nil for any ID that wasn't a primary-key match in the media table (including
// series_id values and virtual msgo-series-* IDs), so SimilarItems always
// returned empty for series detail pages.
func TestEmbyServiceSimilarItemsSeriesID(t *testing.T) {
svc := newTestEmbyService(t)
svc.SetDiscovery(NewMediaDiscoveryService(zap.NewNop(), svc.repo))
lib := model.Library{Name: "TV", Path: "/media/tv", Type: "tv", Enabled: true}
if err := svc.repo.Library.Create(context.Background(), &lib); err != nil {
t.Fatal(err)
}
// Two series with the same genre so they score > 0 for similarity.
ep1 := model.Media{
LibraryID: lib.ID,
SeriesID: "real-series-1",
Title: "剧一",
Genres: "Action",
SeasonNum: 1,
EpisodeNum: 1,
Path: "/media/tv/s1e1.mkv",
}
ep2 := model.Media{
LibraryID: lib.ID,
SeriesID: "real-series-2",
Title: "剧二",
Genres: "Action",
SeasonNum: 1,
EpisodeNum: 1,
Path: "/media/tv/s2e1.mkv",
}
for _, m := range []*model.Media{&ep1, &ep2} {
if err := svc.repo.DB.Create(m).Error; err != nil {
t.Fatal(err)
}
}
// "real-series-1" is the series_id stored in the media row but is NOT a
// primary key in the media table, so Media.FindByID("real-series-1") returns
// nil. SimilarItems must fall back to findSeriesGroup and still return
// results.
result, err := svc.SimilarItems(context.Background(), "real-series-1", "", 12)
if err != nil {
t.Fatalf("SimilarItems with series_id: %v", err)
}
similar, _ := result["Items"].([]map[string]any)
if similar == nil {
t.Fatal("SimilarItems returned nil items for a series_id that resolves via findSeriesGroup")
}
// Should contain 剧二 (the only other episodic content with the same genre).
found := false
for _, item := range similar {
if name, _ := item["Name"].(string); strings.Contains(name, "剧二") || strings.Contains(name, "第 1 集") {
found = true
break
}
}
if !found && len(similar) == 0 {
t.Fatalf("SimilarItems returned no results; want at least 剧二 for series real-series-1")
}
}
// SimilarItems with a virtual series ID (msgo-series-*) must also work.
// Virtual IDs are generated for episodes that have no series_id set.
func TestEmbyServiceSimilarItemsVirtualSeriesID(t *testing.T) {
svc := newTestEmbyService(t)
svc.SetDiscovery(NewMediaDiscoveryService(zap.NewNop(), svc.repo))
lib := model.Library{Name: "TV2", Path: "/media/tv2", Type: "tv", Enabled: true}
if err := svc.repo.Library.Create(context.Background(), &lib); err != nil {
t.Fatal(err)
}
// Episodes WITHOUT SeriesID → series group gets a virtual msgo-series-* ID.
ep1 := model.Media{
LibraryID: lib.ID,
Title: "虚拟剧一",
Genres: "Drama",
SeasonNum: 1,
EpisodeNum: 1,
Path: "/media/tv2/virtual1/S01E01.mkv",
}
ep2 := model.Media{
LibraryID: lib.ID,
Title: "虚拟剧二",
Genres: "Drama",
SeasonNum: 1,
EpisodeNum: 1,
Path: "/media/tv2/virtual2/S01E01.mkv",
}
for _, m := range []*model.Media{&ep1, &ep2} {
if err := svc.repo.DB.Create(m).Error; err != nil {
t.Fatal(err)
}
}
// Fetch series items to get the virtual ID for ep1's series.
seriesItems, err := svc.Items(context.Background(), ItemsParams{
IncludeItemTypes: []string{"Series"},
Recursive: true,
})
if err != nil {
t.Fatal(err)
}
items, _ := seriesItems["Items"].([]map[string]any)
var virtualID string
for _, item := range items {
id, _ := item["Id"].(string)
name, _ := item["Name"].(string)
if strings.Contains(name, "虚拟剧一") && strings.HasPrefix(id, embyVirtualSeriesPrefix) {
virtualID = id
break
}
}
if virtualID == "" {
t.Skip("virtual series ID not generated for episodes without series_id in this build")
}
result, err := svc.SimilarItems(context.Background(), virtualID, "", 12)
if err != nil {
t.Fatalf("SimilarItems with virtual series ID: %v", err)
}
similar, _ := result["Items"].([]map[string]any)
if similar == nil {
t.Fatalf("SimilarItems returned nil items for virtual series ID %q", virtualID)
}
}
+342
View File
@@ -0,0 +1,342 @@
package service
import (
"context"
"crypto/sha1"
"encoding/hex"
"strings"
"time"
"github.com/truewhile/MeBox/internal/model"
)
// Emby 发现类接口:NextUp / Similar / Genres。
//
// 候选集的选取交给 MediaDiscoveryService(纯查询、无 DTO 概念),本文件只做
// 「候选 → Emby DTO」的映射,复用 itemPayload 以保证与 /Items 的形状一致。
const (
embyNextUpDefaultLimit = 20
embySimilarDefaultLimit = 12
embyNextUpMaxLimit = 100
embySimilarMaxLimit = 50
)
// NextUp 返回「每部在看的剧的下一集」,即 Emby 客户端首页「接下来播放」的数据源。
// seriesID 非空时只返回该剧的下一集(剧集详情页「继续播放」);空则返回全站列表。
func (e *EmbyService) NextUp(ctx context.Context, userID, seriesID string, limit int) (map[string]any, error) {
if limit <= 0 {
limit = embyNextUpDefaultLimit
}
if limit > embyNextUpMaxLimit {
limit = embyNextUpMaxLimit
}
if strings.TrimSpace(userID) == "" {
return emptyItemsEnvelope(0), nil
}
seriesID = strings.TrimSpace(seriesID)
discovery := e.discoveryService()
if discovery == nil {
return emptyItemsEnvelope(0), nil
}
if seriesID != "" {
return e.nextUpForSeries(ctx, userID, seriesID, limit)
}
rows, err := discovery.NextUpCandidates(ctx, userID, limit, e.mediaVisibility(ctx, userID))
if err != nil {
return nil, err
}
// Bug 3 fix: use payloadsForMedia which attaches the request-scoped payload
// cache (withPayloadCache + prefetchPayloadCache) to avoid N+1 DB queries.
items, err := e.payloadsForMedia(ctx, rows, userID)
if err != nil {
return nil, err
}
return map[string]any{
"Items": items,
"TotalRecordCount": int64(len(items)),
}, nil
}
// nextUpForSeries 只解析指定剧的下一集。远程挂载剧集按本机播放历史 + 远程
// 分集列表计算,避免把其它本地剧的 NextUp 塞进详情页继续播放按钮。
func (e *EmbyService) nextUpForSeries(ctx context.Context, userID, seriesID string, limit int) (map[string]any, error) {
if IsEmbyRemoteID(seriesID) {
return e.nextUpForRemoteSeries(ctx, userID, seriesID, limit)
}
discovery := e.discoveryService()
if discovery == nil {
return emptyItemsEnvelope(0), nil
}
// 多取候选再按 SeriesId 精确过滤,避免「全站 TopN」把目标剧挤掉。
scanLimit := embyNextUpMaxLimit
if limit > scanLimit {
scanLimit = limit
}
rows, err := discovery.NextUpCandidates(ctx, userID, scanLimit, e.mediaVisibility(ctx, userID))
if err != nil {
return nil, err
}
items, err := e.payloadsForMedia(ctx, rows, userID)
if err != nil {
return nil, err
}
filtered := make([]map[string]any, 0, 1)
for _, item := range items {
itemSeries, _ := item["SeriesId"].(string)
if itemSeries == seriesID {
filtered = append(filtered, item)
if len(filtered) >= limit {
break
}
}
}
return map[string]any{
"Items": filtered,
"TotalRecordCount": int64(len(filtered)),
}, nil
}
// nextUpForRemoteSeries 用 MeBox 本地播放历史在远程剧的分集里找「下一集」。
// 不透传远程账号的 NextUp,避免多用户共用挂载账号时串进度。
func (e *EmbyService) nextUpForRemoteSeries(ctx context.Context, userID, seriesID string, limit int) (map[string]any, error) {
if e == nil || e.remote == nil || strings.TrimSpace(userID) == "" {
return emptyItemsEnvelope(0), nil
}
if limit <= 0 {
limit = 1
}
mountID, remoteSeriesID, ok := DecodeEmbyRemoteID(seriesID)
if !ok {
return emptyItemsEnvelope(0), nil
}
mount, acct, err := e.remote.ResolveMount(ctx, mountID)
if err != nil || mount == nil || acct == nil {
return emptyItemsEnvelope(0), nil
}
if !EmbyMountLibraryAllowed(e.mediaVisibility(ctx, userID), mount) {
return emptyItemsEnvelope(0), nil
}
prefix := EmbyRemoteIDPrefix + mountID + "~"
var hist []model.PlaybackHistory
if err := e.repo.DB.WithContext(ctx).
Where("user_id = ? AND position_ms > 0 AND media_id LIKE ?", userID, prefix+"%").
Order("watched_at desc").
Limit(nextUpHistoryScanLimit).
Find(&hist).Error; err != nil {
return nil, err
}
if len(hist) == 0 {
return emptyItemsEnvelope(0), nil
}
episodes, err := e.remote.RemoteEpisodes(ctx, mount, acct, remoteSeriesID)
if err != nil || len(episodes) == 0 {
return emptyItemsEnvelope(0), nil
}
epByID := make(map[string]*model.Media, len(episodes))
for i := range episodes {
epByID[episodes[i].ID] = &episodes[i]
}
var current *model.Media
currentCompleted := false
for i := range hist {
if m := epByID[hist[i].MediaID]; m != nil {
current = m
currentCompleted = hist[i].Completed
break
}
}
if current == nil {
return emptyItemsEnvelope(0), nil
}
completed := map[string]bool{}
epIDs := make([]string, 0, len(episodes))
for i := range episodes {
epIDs = append(epIDs, episodes[i].ID)
}
var done []model.PlaybackHistory
if err := e.repo.DB.WithContext(ctx).
Where("user_id = ? AND completed = ? AND media_id IN ?", userID, true, epIDs).
Find(&done).Error; err == nil {
for _, h := range done {
completed[h.MediaID] = true
}
}
next, ok := pickNextEpisode(episodes, current, currentCompleted, completed)
if !ok {
return emptyItemsEnvelope(0), nil
}
_, remoteEpID, ok := DecodeEmbyRemoteID(next.ID)
if !ok {
return emptyItemsEnvelope(0), nil
}
item, err := e.remote.RemoteItem(ctx, mount, acct, remoteEpID)
if err != nil || item == nil {
return emptyItemsEnvelope(0), nil
}
if err := e.mergeRemoteUserData(ctx, userID, item); err != nil {
return nil, err
}
items := []map[string]any{item}
if limit < len(items) {
items = items[:limit]
}
return map[string]any{
"Items": items,
"TotalRecordCount": int64(len(items)),
}, nil
}
// SimilarItems 返回与指定条目相似的本地媒体。
//
// 找不到条目(或该条目对当前用户不可见)时返回空列表而不是错误:客户端会在
// 详情页无条件请求它,404/500 会让客户端把条目判定为不完整。
func (e *EmbyService) SimilarItems(ctx context.Context, mediaID, userID string, limit int) (map[string]any, error) {
if limit <= 0 {
limit = embySimilarDefaultLimit
}
if limit > embySimilarMaxLimit {
limit = embySimilarMaxLimit
}
discovery := e.discoveryService()
if discovery == nil || strings.TrimSpace(mediaID) == "" {
return emptyItemsEnvelope(0), nil
}
// 详情页每次打开都会请求相似推荐,而重建要走「取候选池 + 内存打分」
// (实测冷 340ms / 热 70ms)。推荐列表短暂陈旧无害,用短 TTL 缓存,
// 新建库或换用户都会因为键名不同而自然隔离。
cacheKey := e.embySimilarCacheKey(mediaID, userID, limit)
if e.cache != nil {
var cached map[string]any
if e.cache.GetJSON(ctx, cacheKey, &cached) && cached != nil {
if _, ok := cached["Items"]; ok {
return cached, nil
}
}
}
// Bug 2 fix: resolve virtual series IDs (msgo-series-*) and real series
// table IDs to a representative episode so SimilarCandidates (which calls
// Media.FindByID) can seed similarity from concrete media metadata.
resolvedID := mediaID
if strings.HasPrefix(mediaID, embyVirtualSeriesPrefix) {
series, ok, err := e.findSeriesGroup(ctx, mediaID, userID)
if err != nil {
return nil, err
}
if !ok || len(series.Episodes) == 0 {
return emptyItemsEnvelope(0), nil
}
resolvedID = series.Episodes[0].ID
} else if e.repo != nil && e.repo.Media != nil {
// For non-virtual IDs that are series-level (not in media table), also
// resolve via findSeriesGroup so the seed row can be found.
m, err := e.repo.Media.FindByID(ctx, mediaID)
if err != nil {
return nil, err
}
if m == nil {
series, ok, err := e.findSeriesGroup(ctx, mediaID, userID)
if err != nil {
return nil, err
}
if !ok || len(series.Episodes) == 0 {
return emptyItemsEnvelope(0), nil
}
resolvedID = series.Episodes[0].ID
}
}
rows, err := discovery.SimilarCandidates(ctx, resolvedID, limit, e.mediaVisibility(ctx, userID))
if err != nil {
return nil, err
}
// Bug 3 fix: use payloadsForMedia which attaches the request-scoped payload
// cache (withPayloadCache + prefetchPayloadCache) to avoid N+1 DB queries.
items, err := e.payloadsForMedia(ctx, rows, userID)
if err != nil {
return nil, err
}
out := map[string]any{
"Items": items,
"TotalRecordCount": int64(len(items)),
}
if e.cache != nil {
e.cache.SetJSON(ctx, cacheKey, out, embySimilarCacheTTL)
}
return out, nil
}
// embySimilarCacheTTL 是「相似推荐」结果的缓存时长。列表只是推荐,短暂陈旧
// 无害;TTL 取短一些,让新入库的内容尽快出现。
const embySimilarCacheTTL = 2 * time.Minute
// Genres 返回类型清单。parentID 非空时(客户端按媒体库浏览类型)只统计该库。
func (e *EmbyService) Genres(ctx context.Context, userID, parentID string) (map[string]any, error) {
discovery := e.discoveryService()
if discovery == nil {
return emptyItemsEnvelope(0), nil
}
libraryID := ""
if trimmed := strings.TrimSpace(parentID); trimmed != "" {
// 只有本地的真实库 ID 才能用于收窄;虚拟视图 ID(Emby 客户端自己的
// 视图标识)收窄后会得到空结果,因此识别不出来时按全库统计。
if e.libraryExists(ctx, trimmed) {
libraryID = trimmed
}
}
genres, err := discovery.AggregateGenres(ctx, e.mediaVisibility(ctx, userID), libraryID)
if err != nil {
return nil, err
}
items := make([]map[string]any, 0, len(genres))
for _, genre := range genres {
items = append(items, map[string]any{
"Id": embyGenreID(genre.Name),
"Name": genre.Name,
"ItemCount": genre.Count,
"Type": "Genre",
"ServerId": embyServerID,
"IsFolder": false,
"CanDelete": false,
"CanDownload": false,
"ImageTags": map[string]any{},
"BackdropImageTags": []any{},
})
}
return map[string]any{
"Items": items,
"TotalRecordCount": int64(len(items)),
}, nil
}
// libraryExists 判断 ID 是否对应本地媒体库。
func (e *EmbyService) libraryExists(ctx context.Context, id string) bool {
if e == nil || e.repo == nil || e.repo.Library == nil {
return false
}
lib, err := e.repo.Library.FindByID(ctx, id)
if err != nil {
return false
}
return lib != nil
}
// embyGenreID 为类型生成稳定的虚拟 ID。
//
// 客户端会把 Id 当作条目去请求图片/详情,直接用类型名会带上空格与非 ASCII
// 字符,因此用固定前缀 + 名称哈希;同一个名称永远得到同一个 ID。
func embyGenreID(name string) string {
sum := sha1.Sum([]byte(strings.ToLower(strings.TrimSpace(name))))
return "msgo-genre-" + hex.EncodeToString(sum[:])[:16]
}
+19
View File
@@ -47,16 +47,35 @@ func (e *EmbyService) embyItemsCacheKey(kind string, p ItemsParams) string {
p.SortOrder,
strconv.Itoa(p.StartIndex),
strconv.Itoa(p.Limit),
formatSeasonIndexCacheKeyPart(p.SeasonIndex),
}, "|")))
return "media:emby:" + hex.EncodeToString(sum[:])
}
// formatSeasonIndexCacheKeyPart keeps "no season filter" distinct from "season 0"
// so a client's Season=0 (specials) request never reuses an unfiltered response.
func formatSeasonIndexCacheKeyPart(seasonIndex *int) string {
if seasonIndex == nil {
return "season:*"
}
return "season:" + strconv.Itoa(*seasonIndex)
}
func (e *EmbyService) embyLatestCacheKey(userID, parentID string, limit int) string {
// v2: payload tags for virtual artwork changed so clients drop cached placeholders.
sum := sha256.Sum256([]byte(strings.Join([]string{"latest-v2", userID, parentID, strconv.Itoa(limit)}, "|")))
return "media:emby:" + hex.EncodeToString(sum[:])
}
// embySimilarCacheKey 是「相似推荐」结果的缓存键。
//
// userID 必须参与键名:候选集的可见性(AllowedLibraryIDs、NSFW)由用户决定,
// 混用会把别的用户可见的条目推荐给当前用户。limit 同理影响结果条数与排序。
func (e *EmbyService) embySimilarCacheKey(mediaID, userID string, limit int) string {
sum := sha256.Sum256([]byte(strings.Join([]string{"similar-v1", mediaID, userID, strconv.Itoa(limit)}, "|")))
return "media:emby:" + hex.EncodeToString(sum[:])
}
// defaultEmbyLatestCacheTTLSeconds 是 Emby「最新添加」缓存的兜底时长。
const defaultEmbyLatestCacheTTLSeconds = 300
+63 -15
View File
@@ -38,14 +38,6 @@ func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[str
if err := e.mergeRemoteUserData(ctx, userID, out); err != nil {
return nil, err
}
if favorite, _ := IsUserFavorite(ctx, e.repo, userID, mediaID); favorite {
userData, _ := out["UserData"].(map[string]any)
if userData == nil {
userData = map[string]any{}
out["UserData"] = userData
}
userData["IsFavorite"] = true
}
return out, nil
}
if lib, err := e.repo.Library.FindByID(ctx, mediaID); err != nil {
@@ -307,8 +299,14 @@ func embyLatestSeriesRowLimit(limit int) int {
}
// ResumeItems 列出有未完成播放进度的媒体。
func (e *EmbyService) ResumeItems(ctx context.Context, userID string, limit int) (map[string]any, error) {
return e.resumableItems(ctx, ItemsParams{UserID: userID, Limit: limit})
// parentID 非空时收窄到该库 / 该剧(含虚拟 msgo-series-* ID)。
func (e *EmbyService) ResumeItems(ctx context.Context, userID, parentID string, limit, startIndex int) (map[string]any, error) {
return e.resumableItems(ctx, ItemsParams{
UserID: userID,
ParentID: strings.TrimSpace(parentID),
Limit: limit,
StartIndex: startIndex,
})
}
// favoriteItems returns favourited media for Emby clients, including mounted
@@ -442,6 +440,30 @@ func favoriteMatchesParent(ctx context.Context, e *EmbyService, parentID, mediaI
return wantMountID != "" && gotMountID == wantMountID
}
// resumeMatchesParent 判断续播条目是否属于 ParentId / SeriesId 作用域。
// 本地剧集的 series_id 常为空,实际对外 ID 是 msgo-series-* 虚拟 ID,必须用
// seriesIDForMedia 对齐,否则按剧收窄永远匹配不上。
func resumeMatchesParent(ctx context.Context, e *EmbyService, parentID, libraryID, seriesID string, m *model.Media) bool {
if parentID == "" {
return true
}
if libraryID == parentID || seriesID == parentID {
return true
}
if m != nil && e.seriesIDForMedia(ctx, m) == parentID {
return true
}
if m != nil && e.seasonIDForMedia(ctx, m) == parentID {
return true
}
for _, id := range e.mergedLibraryIDs(ctx, parentID) {
if id == libraryID {
return true
}
}
return false
}
// resumableItems 返回未完成播放进度的媒体(包含本地媒体与挂载的远程媒体),支持分页。
func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[string]any, error) {
if p.Limit <= 0 || p.Limit > 100 {
@@ -466,12 +488,26 @@ func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[st
return map[string]any{"Items": []any{}, "TotalRecordCount": int64(0), "StartIndex": p.StartIndex}, nil
}
mediaIDs := make([]string, 0, len(hist))
localIDs := make([]string, 0, len(hist))
for _, h := range hist {
mediaIDs = append(mediaIDs, h.MediaID)
if !IsEmbyRemoteID(h.MediaID) {
localIDs = append(localIDs, h.MediaID)
}
}
favSet := map[string]bool{}
if len(mediaIDs) > 0 {
var favs []model.Favorite
if err := e.repo.DB.WithContext(ctx).
Where("user_id = ? AND media_id IN ?", p.UserID, mediaIDs).
Find(&favs).Error; err != nil {
return nil, err
}
for _, fav := range favs {
favSet[fav.MediaID] = true
}
}
byID := map[string]*model.Media{}
if len(localIDs) > 0 {
var medias []model.Media
@@ -494,11 +530,11 @@ func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[st
localTotal, remoteTotal := 0, 0
for _, h := range hist {
if m, ok := byID[h.MediaID]; ok {
if p.ParentID != "" && m.LibraryID != p.ParentID && m.SeriesID != p.ParentID {
if p.ParentID != "" && !resumeMatchesParent(ctx, e, p.ParentID, m.LibraryID, m.SeriesID, m) {
continue
}
localTotal++
slots = append(slots, resumeSlot{item: e.itemPayload(ctx, m, false, h.PositionMs, false)})
slots = append(slots, resumeSlot{item: e.itemPayload(ctx, m, favSet[h.MediaID], h.PositionMs, false)})
continue
}
if e.remote == nil || !IsEmbyRemoteID(h.MediaID) {
@@ -559,7 +595,7 @@ func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[st
continue
}
}
item["UserData"] = mergedRemoteUserData(item["UserData"], &f.hist)
item["UserData"] = applyMeBoxUserData(item["UserData"], &f.hist, favSet[f.hist.MediaID])
slots[i].item = item
}
}
@@ -646,9 +682,21 @@ func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool,
seasonID = e.seasonIDForMedia(ctx, m)
parentID = seasonID
episodeTitle := strings.TrimSpace(m.EpisodeTitle)
if episodeTitle != "" {
// 半集(S01E11.5)在 Emby 协议里只能占用整数 IndexNumber 11,标题里
// 不再带上小数就会和真正的第 11 集完全分不清,所以显式补上集号。
halfLabel := ""
if m.EpisodeFraction > 0 && m.EpisodeNum > 0 {
halfLabel = fmt.Sprintf("第 %s 集", FormatEpisodeNumber(m.EpisodeNum, m.EpisodeFraction))
}
switch {
case episodeTitle != "":
name = episodeTitle
} else if m.EpisodeNum > 0 {
if halfLabel != "" && !strings.HasPrefix(episodeTitle, halfLabel) {
name = halfLabel + " · " + episodeTitle
}
case halfLabel != "":
name = halfLabel
case m.EpisodeNum > 0:
name = fmt.Sprintf("第 %d 集", m.EpisodeNum)
}
}
+28 -6
View File
@@ -121,16 +121,31 @@ func (e *EmbyService) mediaItems(ctx context.Context, p ItemsParams) (map[string
func (e *EmbyService) episodeItems(ctx context.Context, rows []model.Media, p ItemsParams) (map[string]any, error) {
rows = e.filterMediaRowsForUser(ctx, rows, p.UserID)
if p.SearchTerm != "" {
filtered := rows[:0]
needle := strings.ToLower(p.SearchTerm)
for _, row := range rows {
if strings.Contains(strings.ToLower(row.Title), needle) || strings.Contains(strings.ToLower(row.OriginalName), needle) {
filtered = append(filtered, row)
// rows 可能来自 series 分组的内存 memo(embySeriesGroup.Episodes)。过滤必须
// 分配新切片:就地复用 rows[:0] 会覆写 memo 里的元素,让后续请求看到被前一次
// 过滤污染的剧集列表(例如按季筛选一次之后,特别篇就从缓存分组里消失了)。
if p.SeasonIndex != nil {
filtered := make([]model.Media, 0, len(rows))
for i := range rows {
if embyRowMatchesSeasonIndex(&rows[i], p.SeasonIndex) {
filtered = append(filtered, rows[i])
}
}
rows = filtered
}
if p.SearchTerm != "" {
needle := strings.ToLower(p.SearchTerm)
filtered := make([]model.Media, 0, len(rows))
for i := range rows {
if strings.Contains(strings.ToLower(rows[i].Title), needle) || strings.Contains(strings.ToLower(rows[i].OriginalName), needle) {
filtered = append(filtered, rows[i])
}
}
rows = filtered
}
// sort.SliceStable 同样会就地重排:先拷贝一份,避免把 memo 分组里的剧集顺序
// 按每次请求的分页/筛选结果固定下来。
rows = append([]model.Media(nil), rows...)
sort.SliceStable(rows, func(i, j int) bool {
if rows[i].SeasonNum != rows[j].SeasonNum {
return rows[i].SeasonNum < rows[j].SeasonNum
@@ -138,8 +153,15 @@ func (e *EmbyService) episodeItems(ctx context.Context, rows []model.Media, p It
if rows[i].EpisodeNum != rows[j].EpisodeNum {
return rows[i].EpisodeNum < rows[j].EpisodeNum
}
if rows[i].EpisodeFraction != rows[j].EpisodeFraction {
return rows[i].EpisodeFraction < rows[j].EpisodeFraction
}
return rows[i].CreatedAt.Before(rows[j].CreatedAt)
})
// 先折叠同集的多个版本再统计总数与分页,与 payloadsForMedia 内部保持同一步骤。
// 若按未折叠的行数报 TotalRecordCount(例如 S01E11 与 S01E11.5 两行折叠成一条),
// 客户端会认为还有一条没取到,反复请求下一页 —— 分集列表就会一直加载不出来。
rows = e.collapseMediaVersionRows(ctx, rows)
total := len(rows)
items, err := e.payloadsForMedia(ctx, pageSlice(rows, p.StartIndex, p.Limit), p.UserID)
if err != nil {
+10 -6
View File
@@ -33,7 +33,7 @@ func (e *EmbyService) mediaVersionSiblings(ctx context.Context, m *model.Media)
}
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).
Where("library_id IN ?", libraryIDs).
Where("season_num = ? AND episode_num = ?", m.SeasonNum, m.EpisodeNum)
Where("season_num = ? AND episode_num = ? AND episode_fraction = ?", m.SeasonNum, m.EpisodeNum, m.EpisodeFraction)
if m.TMDbID > 0 {
q = q.Where("tm_db_id = ?", m.TMDbID)
} else if m.BangumiID > 0 {
@@ -110,11 +110,15 @@ func (e *EmbyService) mediaVersionKey(ctx context.Context, m *model.Media) strin
}
kind := mediaSpecialKind(m.Path)
season, episode := m.SeasonNum, m.EpisodeNum
fraction := m.EpisodeFraction
if kind != "" && kind != mediaSpecialTheatrical && episode <= 0 {
if parsedSeason, parsedEpisode := ParseEpisode(m.Path); parsedEpisode > 0 {
season, episode = parsedSeason, parsedEpisode
if parsedSeason, parsedEpisode, parsedFraction := ParseEpisodeParts(m.Path); parsedEpisode > 0 {
season, episode, fraction = parsedSeason, parsedEpisode, parsedFraction
}
}
// 集号后缀让 S01E11.5 与 S01E11 属于不同版本组;没有小数时后缀为空,
// 键与历史完全一致。
fractionSuffix := episodeFractionSuffix(fraction)
kindKey := ""
if kind != "" {
kindKey = "|kind:" + kind
@@ -126,10 +130,10 @@ func (e *EmbyService) mediaVersionKey(ctx context.Context, m *model.Media) strin
return libraryGroup + "|special-item:" + kind + "|id:" + m.ID
}
if m.TMDbID > 0 {
return fmt.Sprintf("%s|tmdb:%d|s:%d|e:%d%s", libraryGroup, m.TMDbID, season, episode, kindKey)
return fmt.Sprintf("%s|tmdb:%d|s:%d|e:%d%s%s", libraryGroup, m.TMDbID, season, episode, fractionSuffix, kindKey)
}
if m.BangumiID > 0 {
return fmt.Sprintf("%s|bangumi:%d|s:%d|e:%d%s", libraryGroup, m.BangumiID, season, episode, kindKey)
return fmt.Sprintf("%s|bangumi:%d|s:%d|e:%d%s%s", libraryGroup, m.BangumiID, season, episode, fractionSuffix, kindKey)
}
title := strings.ToLower(strings.TrimSpace(m.Title))
if title == "" {
@@ -138,7 +142,7 @@ func (e *EmbyService) mediaVersionKey(ctx context.Context, m *model.Media) strin
if title == "" {
return ""
}
return fmt.Sprintf("%s|title:%s|y:%d|s:%d|e:%d%s", libraryGroup, title, m.Year, season, episode, kindKey)
return fmt.Sprintf("%s|title:%s|y:%d|s:%d|e:%d%s%s", libraryGroup, title, m.Year, season, episode, fractionSuffix, kindKey)
}
func preferMediaVersion(candidate, current model.Media) bool {
+239 -20
View File
@@ -79,6 +79,61 @@ type EmbyRemoteService struct {
personMu sync.RWMutex
personImages map[string]embyRemotePersonImageRef
// imageTagMu 保护 imageTags:远程条目图片标签缓存,键为
// imageTagKey(accountID, remoteID, imageType)。它让图片 URL 带上远端
// ImageTags,从而在远端换图后让本地磁盘缓存与客户端缓存一起失效
// (没有它时 URL 恒定,缩略图会永久停留在旧版本)。
imageTagMu sync.RWMutex
imageTags map[string]string
// remoteGate 是发往远程 Emby 的并发闸门。第三方客户端刷新首页时会为每个
// 远程媒体库各请求一次 /Items/Latest,挂着几十个库就是几十路并发(生产环境
// 实测 50 路同时打进来,单个请求被拖到 5s+)。限制在途请求数后单个请求的
// 等待时间反而下降,也不会把 2C 小机和对方服务器一起打满。
//
// nil 表示不限流(测试直接构造结构体时走这条路)。
remoteGate chan struct{}
}
// embyRemoteConcurrencyLimit 是同时发往远程 Emby 的请求数上限。
const embyRemoteConcurrencyLimit = 8
// enterRemoteGate 取得一个远程请求名额,返回释放函数。未配置闸门时返回空操作。
func (r *EmbyRemoteService) enterRemoteGate(ctx context.Context) (func(), error) {
if r == nil || r.remoteGate == nil {
return func() {}, nil
}
select {
case r.remoteGate <- struct{}{}:
return func() { <-r.remoteGate }, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
// fetchRemoteBody 在并发闸门内发起请求并读完响应体,返回状态码与字节。
func (r *EmbyRemoteService) fetchRemoteBody(ctx context.Context, req *http.Request, path string) (int, []byte, error) {
release, err := r.enterRemoteGate(ctx)
if err != nil {
return 0, nil, err
}
defer release()
resp, err := r.http.Do(req)
if err != nil {
return 0, nil, redactSensitiveError(fmt.Errorf("请求远程 Emby 失败: %w", err))
}
defer resp.Body.Close()
// 读 8MB+1 以区分"刚好 8MB"与"被截断":截断的 JSON 会让
// Unmarshal 报 unexpected end,难以定位;这里显式报错。
data, readErr := io.ReadAll(io.LimitReader(resp.Body, (8<<20)+1))
if readErr != nil {
return resp.StatusCode, nil, readErr
}
if len(data) > 8<<20 {
return resp.StatusCode, data, fmt.Errorf("远程 Emby 响应超过 8MB 上限(路径 %s):请减小分页或 Fields 字段", path)
}
return resp.StatusCode, data, nil
}
type embyRemotePersonImageRef struct {
@@ -86,6 +141,9 @@ type embyRemotePersonImageRef struct {
remoteID string
}
// embyRemoteMaxImageTags 限制图片标签映射的条目数,避免长期运行后无界增长。
const embyRemoteMaxImageTags = 20000
// NewEmbyRemoteService 构造远程 Emby 聚合服务。
func NewEmbyRemoteService(cfg *config.Config, log *zap.Logger, repo *repository.Container, crypto *CryptoService) *EmbyRemoteService {
return &EmbyRemoteService{
@@ -103,6 +161,7 @@ func NewEmbyRemoteService(cfg *config.Config, log *zap.Logger, repo *repository.
stream: &http.Client{
Transport: &embyRemoteTransport{base: http.DefaultTransport},
},
remoteGate: make(chan struct{}, embyRemoteConcurrencyLimit),
}
}
@@ -635,21 +694,11 @@ func (r *EmbyRemoteService) doGetOnLine(ctx context.Context, acct *model.StrmAcc
return err
}
req.Header.Set("X-Emby-Token", cfg.Token)
resp, err := r.http.Do(req)
status, data, err := r.fetchRemoteBody(ctx, req, path)
if err != nil {
return redactSensitiveError(fmt.Errorf("请求远程 Emby 失败: %w", err))
return err
}
// 读 8MB+1 以区分"刚好 8MB"与"被截断":截断的 JSON 会让
// Unmarshal 报 unexpected end,难以定位;这里显式报错。
data, readErr := io.ReadAll(io.LimitReader(resp.Body, (8<<20)+1))
resp.Body.Close()
if readErr != nil {
return readErr
}
if len(data) > 8<<20 {
return fmt.Errorf("远程 Emby 响应超过 8MB 上限(路径 %s):请减小分页或 Fields 字段", path)
}
if resp.StatusCode == http.StatusUnauthorized && attempt == 0 {
if status == http.StatusUnauthorized && attempt == 0 {
// 401:只清当前线路的内存 token 并立即重认证;不在此时删除
// DB 里的 api_key——①外层还会按线路故障转移(其他线路可能
// 存有自己的 token);②纯 api_key 账号删除后无法再认证,一次
@@ -663,8 +712,8 @@ func (r *EmbyRemoteService) doGetOnLine(ctx context.Context, acct *model.StrmAcc
master.RemoteUserID = cfg.RemoteUserID
continue
}
if resp.StatusCode >= 300 {
return redactSensitiveError(fmt.Errorf("远程 Emby 请求失败(%d): %s", resp.StatusCode, strings.TrimSpace(string(data))))
if status >= 300 {
return redactSensitiveError(fmt.Errorf("远程 Emby 请求失败(%d): %s", status, strings.TrimSpace(string(data))))
}
if out == nil {
return nil
@@ -819,6 +868,7 @@ func (r *EmbyRemoteService) RemoteItem(ctx context.Context, mount *model.EmbyMou
var cached map[string]any
if r.cache.GetJSON(ctx, cacheKey, &cached) && len(cached) > 0 {
r.rememberRemotePeople(mount, cached)
r.rememberRemoteImageTags(embyRemoteAccountID(acct), cached)
return cached, nil
}
}
@@ -833,6 +883,7 @@ func (r *EmbyRemoteService) RemoteItem(ctx context.Context, mount *model.EmbyMou
return nil, err
}
r.rememberRemotePeople(mount, out)
r.rememberRemoteImageTags(embyRemoteAccountID(acct), out)
RewriteEmbyRemoteIDs(out, mount.ID)
if cacheKey != "" && len(out) > 0 {
r.cache.SetJSON(ctx, cacheKey, out, r.remoteMediaCacheTTL())
@@ -880,6 +931,9 @@ func (r *EmbyRemoteService) RemoteLatestForDisplay(ctx context.Context, mount *m
// RemoteLatestSeries 拉取剧集库最近更新的 Series。部分 Emby 服务不支持
// DateLastContentAdded 或过滤 Series,此时回退到 Latest 并把 Episode 归并到
// 对应 Series。
//
// 使用 Recursive=true 并跳过 anime/ 等中间容器,与 RemoteSeriesCards /
// Emby 客户端列剧集方式一致。
func (r *EmbyRemoteService) RemoteLatestSeries(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteViewID string, limit int) ([]map[string]any, error) {
if limit <= 0 || limit > 100 {
limit = 20
@@ -891,17 +945,32 @@ func (r *EmbyRemoteService) RemoteLatestSeries(ctx context.Context, mount *model
q := url.Values{}
q.Set("ParentId", remoteViewID)
q.Set("IncludeItemTypes", "Series")
q.Set("Recursive", "false")
q.Set("Recursive", "true")
q.Set("SortBy", "DateLastContentAdded")
q.Set("SortOrder", "Descending")
q.Set("Limit", strconv.Itoa(limit))
// 多取一些以便滤掉中间容器后仍够 limit。
q.Set("Limit", strconv.Itoa(limit*4))
q.Set("Fields", "Overview,Genres,ProviderIds,Path,RecursiveItemCount,SeriesPrimaryImage,DateCreated,DateLastMediaAdded,PremiereDate,ProductionYear,CommunityRating,CriticRating")
var body struct {
Items []map[string]any `json:"Items"`
}
if err := r.doGet(ctx, acct, cfg, "/Users/"+url.PathEscape(r.remoteUserID(cfg))+"/Items", q, &body); err == nil && len(body.Items) > 0 {
RewriteEmbyRemoteIDs(body.Items, mount.ID)
return body.Items, nil
filtered := make([]map[string]any, 0, limit)
for _, it := range body.Items {
name := remoteItemString(it, "Name")
path := remoteItemString(it, "Path")
if remoteSeriesItemLooksLikeContainer(name, path) {
continue
}
filtered = append(filtered, it)
if len(filtered) >= limit {
break
}
}
if len(filtered) > 0 {
RewriteEmbyRemoteIDs(filtered, mount.ID)
return filtered, nil
}
}
items, err := r.RemoteLatest(ctx, mount, acct, remoteViewID, limit)
@@ -1135,6 +1204,150 @@ func rewriteSubtitleDeliveryURLs(src map[string]any, playURL string, cfg *EmbyRe
}
}
// embyRemoteImageTagType 归一化图片类型:Emby 的 Art 与 Backdrop 指同一张图,
// 载荷里的 ImageTags 只会有 Primary / Backdrop 两个键。
func embyRemoteImageTagType(imageType string) string {
switch strings.ToLower(strings.TrimSpace(imageType)) {
case "primary", "poster":
return "Primary"
case "backdrop", "art", "background":
return "Backdrop"
default:
return ""
}
}
// remoteItemImageTag 从远程条目载荷读取某一类图片的原始 tag。载荷可能已被
// RewriteEmbyRemoteIDs 伪装过(tag 变成 embyremote~scope~tag),此处会还原。
func remoteItemImageTag(item map[string]any, imageType string) string {
typ := embyRemoteImageTagType(imageType)
if item == nil || typ == "" {
return ""
}
var raw string
switch tags := item["ImageTags"].(type) {
case map[string]any:
raw = anyString(tags[typ])
case map[string]string:
raw = tags[typ]
}
if raw == "" && typ == "Backdrop" {
switch tags := item["BackdropImageTags"].(type) {
case []any:
if len(tags) > 0 {
raw = anyString(tags[0])
}
case []string:
if len(tags) > 0 {
raw = tags[0]
}
}
}
if _, original, ok := DecodeEmbyRemoteID(raw); ok {
return original
}
return strings.TrimSpace(raw)
}
// imageTagKey 是图片标签映射的键;不认识的图片类型返回空串(不记录)。
func imageTagKey(accountID, remoteID, imageType string) string {
typ := embyRemoteImageTagType(imageType)
if typ == "" || strings.TrimSpace(remoteID) == "" || strings.TrimSpace(accountID) == "" {
return ""
}
return accountID + "|" + remoteID + "|" + typ
}
// rememberRemoteImageTagValue 记录单条图片标签(供 SeriesPrimaryImageTag 这类
// 散落在载荷其他字段里的标签使用)。
func (r *EmbyRemoteService) rememberRemoteImageTagValue(accountID, remoteID, imageType, tag string) {
if r == nil || strings.TrimSpace(tag) == "" {
return
}
if _, original, ok := DecodeEmbyRemoteID(tag); ok {
tag = original
}
key := imageTagKey(accountID, remoteID, imageType)
if key == "" {
return
}
r.imageTagMu.Lock()
defer r.imageTagMu.Unlock()
if r.imageTags == nil || len(r.imageTags) > embyRemoteMaxImageTags {
r.imageTags = make(map[string]string, 256)
}
r.imageTags[key] = tag
}
// rememberRemoteImageTags 记录载荷里出现的图片标签。载荷可以已被伪装。
func (r *EmbyRemoteService) rememberRemoteImageTags(accountID string, item map[string]any) {
if r == nil || item == nil || strings.TrimSpace(accountID) == "" {
return
}
remoteID := remoteItemString(item, "Id")
if _, original, ok := DecodeEmbyRemoteID(remoteID); ok {
remoteID = original
}
if strings.TrimSpace(remoteID) == "" {
return
}
for _, imageType := range []string{"Primary", "Backdrop"} {
if tag := remoteItemImageTag(item, imageType); tag != "" {
r.rememberRemoteImageTagValue(accountID, remoteID, imageType, tag)
}
}
}
// remoteImageTag 查询已记录的图片标签;未知时返回空串(调用方退化为原行为)。
func (r *EmbyRemoteService) remoteImageTag(accountID, remoteID, imageType string) string {
if r == nil {
return ""
}
key := imageTagKey(accountID, remoteID, imageType)
if key == "" {
return ""
}
r.imageTagMu.RLock()
defer r.imageTagMu.RUnlock()
return r.imageTags[key]
}
// remoteImageTagQuery 返回追加到远程图片地址后的 tag 查询片段(含 & 前缀)。
// Emby 用 tag 作为图片 ETag/cache key:带上它之后,远端换图会改变 MeBox 的
// 磁盘缓存键,缩略图与客户端缓存都会随之失效,而不是永久停留在旧版本。
func (r *EmbyRemoteService) remoteImageTagQuery(accountID, remoteID, imageType string) string {
tag := r.remoteImageTag(accountID, remoteID, imageType)
if tag == "" {
return ""
}
return "&tag=" + url.QueryEscape(tag)
}
// RemoteImageTagOfEncodedID 按伪装 ID 解析已记录的图片标签,供兼容层
// 回报 ImageTag(客户端据此决定是否复用自己缓存的图片)。
func (r *EmbyRemoteService) RemoteImageTagOfEncodedID(ctx context.Context, encodedID, imageType string) string {
if r == nil {
return ""
}
mountID, remoteID, ok := DecodeEmbyRemoteID(encodedID)
if !ok {
return ""
}
_, acct, _ := r.ResolveMount(ctx, mountID)
if acct == nil {
return ""
}
return r.remoteImageTag(acct.ID, remoteID, imageType)
}
// embyRemoteAccountID 空值安全的账号 ID 读取(构建图片 URL 时可能只有账号对象)。
func embyRemoteAccountID(acct *model.StrmAccount) string {
if acct == nil {
return ""
}
return acct.ID
}
// RemoteImageURL 构造远程图片绝对地址(由既有 ImageProxy 拉取透传)。
func (r *EmbyRemoteService) RemoteImageURL(ctx context.Context, acct *model.StrmAccount, remoteID, imageType string) (string, error) {
cfg, err := r.configOf(acct)
@@ -1142,7 +1355,7 @@ func (r *EmbyRemoteService) RemoteImageURL(ctx context.Context, acct *model.Strm
return "", err
}
return r.embyBase(cfg) + "/Items/" + url.PathEscape(remoteID) + "/Images/" + url.PathEscape(strings.ToLower(imageType)) +
"?api_key=" + url.QueryEscape(cfg.Token), nil
"?api_key=" + url.QueryEscape(cfg.Token) + r.remoteImageTagQuery(embyRemoteAccountID(acct), remoteID, imageType), nil
}
// rememberRemotePeople 记录远程人物名称到远程人物 ID 的映射,供旧式
@@ -1443,6 +1656,12 @@ func (r *EmbyRemoteService) doMutateOnLine(ctx context.Context, cfg *EmbyRemoteC
return err
}
req.Header.Set("X-Emby-Token", cfg.Token)
// 状态同步同样走远程并发闸门:它和首页那批 Latest 请求共用对方服务器。
release, err := r.enterRemoteGate(ctx)
if err != nil {
return err
}
defer release()
resp, err := r.http.Do(req)
if err != nil {
return redactSensitiveError(fmt.Errorf("请求远程 Emby 失败: %w", err))
+112
View File
@@ -0,0 +1,112 @@
package service
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
)
// 远程 Emby 的并发闸门必须真的把在途请求数压在上限之内:第三方客户端首页会为
// 每个远程媒体库各请求一次 /Items/Latest,几十个库就是几十路并发。
func TestRemoteGateLimitsConcurrentRequests(t *testing.T) {
const gate = 3
const requests = 12
var mu sync.Mutex
inflight, peak := 0, 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
mu.Lock()
inflight++
if inflight > peak {
peak = inflight
}
mu.Unlock()
time.Sleep(20 * time.Millisecond)
mu.Lock()
inflight--
mu.Unlock()
_, _ = w.Write([]byte(`{"Items":[]}`))
}))
defer srv.Close()
svc := &EmbyRemoteService{http: srv.Client(), remoteGate: make(chan struct{}, gate)}
var wg sync.WaitGroup
for i := 0; i < requests; i++ {
wg.Add(1)
go func() {
defer wg.Done()
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, srv.URL+"/Items/Latest", nil)
if err != nil {
return
}
if _, _, err := svc.fetchRemoteBody(context.Background(), req, "/Items/Latest"); err != nil {
t.Errorf("fetchRemoteBody: %v", err)
}
}()
}
wg.Wait()
mu.Lock()
defer mu.Unlock()
if peak == 0 {
t.Fatal("test server never saw a request")
}
if peak > gate {
t.Fatalf("peak concurrency = %d, want <= %d", peak, gate)
}
}
// 闸门排队时要响应请求取消,不能把整个 HTTP 请求挂死。
func TestRemoteGateHonoursContextCancellation(t *testing.T) {
svc := &EmbyRemoteService{remoteGate: make(chan struct{}, 1)}
svc.remoteGate <- struct{}{} // 占满名额
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := svc.enterRemoteGate(ctx); !errors.Is(err, context.Canceled) {
t.Fatalf("err = %v, want context.Canceled", err)
}
// 释放名额后必须能正常拿到。
<-svc.remoteGate
release, err := svc.enterRemoteGate(context.Background())
if err != nil {
t.Fatalf("enterRemoteGate after release: %v", err)
}
release()
if len(svc.remoteGate) != 0 {
t.Fatalf("gate leaked a permit: len = %d", len(svc.remoteGate))
}
}
// 未配置闸门(测试里直接构造结构体)时不应限流,保持旧行为。
func TestRemoteGateAbsentIsUnlimited(t *testing.T) {
svc := &EmbyRemoteService{}
release, err := svc.enterRemoteGate(context.Background())
if err != nil {
t.Fatalf("enterRemoteGate: %v", err)
}
release()
}
// 生产路径构造出来的服务必须带闸门,否则上面的限制形同虚设。
func TestNewEmbyRemoteServiceInitialisesGate(t *testing.T) {
svc := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), nil, nil)
if svc.remoteGate == nil {
t.Fatal("remoteGate must be initialised by the constructor")
}
if cap(svc.remoteGate) != embyRemoteConcurrencyLimit {
t.Fatalf("gate capacity = %d, want %d", cap(svc.remoteGate), embyRemoteConcurrencyLimit)
}
}
@@ -0,0 +1,173 @@
package service
import (
"strings"
"testing"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
// newImageTagTestService 构造只用于图片标签映射测试的远程 Emby 服务。
func newImageTagTestService(t *testing.T) (*EmbyRemoteService, *model.EmbyMount, *model.StrmAccount) {
t.Helper()
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
repos := repository.New(db)
svc := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
acct := &model.StrmAccount{
Base: model.Base{ID: "acct-tag"},
Name: "tag-emby",
Provider: model.StrmProviderEmbyRemote,
Enabled: true,
Config: `{"url":"http://emby.test:8096","token":"fake-token"}`,
}
mount := &model.EmbyMount{
Base: model.Base{ID: "mount-tag"},
AccountID: acct.ID,
RemoteViewID: "view-tag",
Enabled: true,
}
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
t.Fatalf("create account: %v", err)
}
if err := repos.EmbyMount.Create(t.Context(), mount); err != nil {
t.Fatalf("create mount: %v", err)
}
return svc, mount, acct
}
// testRemoteImageConfig 是图片 URL 构造所需的最小远程配置。
func testRemoteImageConfig() *EmbyRemoteConfig {
return &EmbyRemoteConfig{BaseURL: "http://emby.test:8096", Token: "fake-token"}
}
// TestMapRemoteItemToMediaCarriesImageTags 下发的图片 URL 必须带上远端图片
// 标签,否则远端换图后磁盘/客户端缓存永远不会失效。
func TestMapRemoteItemToMediaCarriesImageTags(t *testing.T) {
svc, mount, acct := newImageTagTestService(t)
media := svc.MapRemoteItemToMedia(t.Context(), mount, acct, testRemoteImageConfig(), map[string]any{
"Id": "movie-1",
"Name": "测试影片",
"Type": "Movie",
"ImageTags": map[string]any{
"Primary": "primary-tag-1",
},
"BackdropImageTags": []any{"backdrop-tag-1"},
})
if !strings.Contains(media.PosterURL, "tag=primary-tag-1") {
t.Fatalf("PosterURL missing remote image tag: %q", media.PosterURL)
}
if !strings.Contains(media.BackdropURL, "tag=backdrop-tag-1") {
t.Fatalf("BackdropURL missing remote image tag: %q", media.BackdropURL)
}
}
// TestMapRemoteItemToMediaCarriesImageTagsAfterRewrite 载荷先经过 ID 伪装
// (tag 变成 embyremote~...~tag)时仍要还原出原始 tag。
func TestMapRemoteItemToMediaCarriesImageTagsAfterRewrite(t *testing.T) {
svc, mount, acct := newImageTagTestService(t)
item := map[string]any{
"Id": "movie-2",
"Name": "伪装过的影片",
"Type": "Movie",
"ImageTags": map[string]any{
"Primary": "primary-tag-2",
},
}
RewriteEmbyRemoteIDs(item, mount.ID)
media := svc.MapRemoteItemToMedia(t.Context(), mount, acct, testRemoteImageConfig(), item)
if !strings.Contains(media.PosterURL, "tag=primary-tag-2") {
t.Fatalf("PosterURL missing decoded remote image tag: %q", media.PosterURL)
}
if strings.Contains(media.PosterURL, EmbyRemoteIDPrefix) {
t.Fatalf("PosterURL leaked disguised tag: %q", media.PosterURL)
}
}
// TestRemoteImageURLCacheKeyFollowsImageTag 远端换图(tag 变化)后,图片 URL
// 必须随之变化,缓存键才会失效。
func TestRemoteImageURLCacheKeyFollowsImageTag(t *testing.T) {
svc, mount, acct := newImageTagTestService(t)
first := svc.MapRemoteItemToMedia(t.Context(), mount, acct, testRemoteImageConfig(), map[string]any{
"Id": "movie-3",
"Name": "换图影片",
"Type": "Movie",
"ImageTags": map[string]any{"Primary": "old-tag"},
})
second := svc.MapRemoteItemToMedia(t.Context(), mount, acct, testRemoteImageConfig(), map[string]any{
"Id": "movie-3",
"Name": "换图影片",
"Type": "Movie",
"ImageTags": map[string]any{"Primary": "new-tag"},
})
if first.PosterURL == second.PosterURL {
t.Fatalf("image URL did not change when the remote tag changed: %q", first.PosterURL)
}
if imageCacheKeyURL(first.PosterURL) == imageCacheKeyURL(second.PosterURL) {
t.Fatal("image cache key did not change when the remote tag changed")
}
}
// TestMapRemoteItemToMediaWithoutImageTagsKeepsURLTagFree 没有标签时保持原样
// (不追加空 tag 参数)。
func TestMapRemoteItemToMediaWithoutImageTagsKeepsURLTagFree(t *testing.T) {
svc, mount, acct := newImageTagTestService(t)
media := svc.MapRemoteItemToMedia(t.Context(), mount, acct, testRemoteImageConfig(), map[string]any{
"Id": "movie-4",
"Name": "无标签影片",
"Type": "Movie",
"ImageTags": map[string]any{"Primary": "primary-tag-4"},
})
if !strings.Contains(media.PosterURL, "tag=primary-tag-4") {
t.Fatalf("PosterURL missing tag: %q", media.PosterURL)
}
if strings.Contains(media.BackdropURL, "tag=") {
t.Fatalf("BackdropURL should not carry a tag when absent: %q", media.BackdropURL)
}
unknown := svc.MapRemoteItemToMedia(t.Context(), mount, acct, testRemoteImageConfig(), map[string]any{
"Id": "movie-5",
"Name": "未知标签影片",
"Type": "Movie",
"ImageTags": map[string]any{
"Primary": "primary-tag-5",
},
})
if strings.Contains(unknown.BackdropURL, "tag=") {
t.Fatalf("unknown image tag should degrade to no tag: %q", unknown.BackdropURL)
}
}
// TestRemoteImageURLIncludesRememberedTag 兼容层(/emby/Items/{id}/Images/...)
// 复用同一份标签映射。
func TestRemoteImageURLIncludesRememberedTag(t *testing.T) {
svc, mount, acct := newImageTagTestService(t)
svc.rememberRemoteImageTags(acct.ID, map[string]any{
"Id": "movie-6",
"ImageTags": map[string]any{"Primary": "compat-tag"},
})
raw, err := svc.RemoteImageURL(t.Context(), acct, "movie-6", "Primary")
if err != nil {
t.Fatalf("RemoteImageURL failed: %v", err)
}
if !strings.Contains(raw, "tag=compat-tag") {
t.Fatalf("remote image URL missing tag: %q", raw)
}
encoded := EncodeEmbyRemoteID(mount.ID, "movie-6")
if got := svc.RemoteImageTagOfEncodedID(t.Context(), encoded, "Primary"); got != "compat-tag" {
t.Fatalf("RemoteImageTagOfEncodedID = %q, want compat-tag", got)
}
}
+240
View File
@@ -0,0 +1,240 @@
package service
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
// 回归:挂载的远程 Emby 剧集里,只看了几秒就退出的那一集必须仍是 NextUp 的结果。
// 客户端(Yamby 等)剧集详情页的「继续播放」直接取 NextUp 第一条,跳集会播错集。
func TestMountedRemoteNextUpKeepsPartiallyWatchedEpisode(t *testing.T) {
episode := func(id string, index int) map[string]any {
return map[string]any{
"Id": id,
"Name": "第" + strconv.Itoa(index) + "集",
"Type": "Episode",
"SeriesId": "series-100",
"ParentIndexNumber": 1,
"IndexNumber": index,
"RunTimeTicks": 14400640000,
}
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/emby/Users/uid-1/Items/series-100":
_ = json.NewEncoder(w).Encode(map[string]any{"Id": "series-100", "Name": "剧一", "Type": "Series"})
case "/emby/Users/uid-1/Items/ep-2":
_ = json.NewEncoder(w).Encode(episode("ep-2", 2))
case "/emby/Users/uid-1/Items":
q := r.URL.Query()
if q.Get("IncludeItemTypes") != "Episode" || q.Get("ParentId") != "series-100" {
w.WriteHeader(http.StatusBadRequest)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"TotalRecordCount": 3,
"Items": []map[string]any{
episode("ep-1", 1),
episode("ep-2", 2),
episode("ep-3", 3),
},
})
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{}, &model.PlaybackHistory{}, &model.User{})
repos := repository.New(db)
cfg := &config.Config{}
remote := NewEmbyRemoteService(cfg, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
svc := NewEmbyService(cfg, zap.NewNop(), repos).SetEmbyRemote(remote)
rawConfig, _ := json.Marshal(map[string]string{
"url": server.URL,
"api_key": "test-api-key",
"remote_user_id": "uid-1",
})
acct := &model.StrmAccount{
Base: model.Base{ID: "acct-1"},
Name: "远程 Emby",
Provider: model.StrmProviderEmbyRemote,
Config: string(rawConfig),
Enabled: true,
}
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
t.Fatalf("create account: %v", err)
}
mount := &model.EmbyMount{
Base: model.Base{ID: "mount-1"},
AccountID: acct.ID,
RemoteViewID: "view-1",
RemoteViewName: "新番连载",
CollectionType: "tvshows",
Enabled: true,
}
if err := repos.EmbyMount.Create(t.Context(), mount); err != nil {
t.Fatalf("create mount: %v", err)
}
user := &model.User{
Base: model.Base{ID: "user-1"},
Username: "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)
}
// 第 2 集只播了 3.5 秒就退出:有进度、未标记看完。
if err := repos.DB.Create(&model.PlaybackHistory{
UserID: user.ID,
MediaID: EncodeEmbyRemoteID(mount.ID, "ep-2"),
PositionMs: 3582,
DurationMs: 1440064,
WatchedAt: time.Now(),
Completed: false,
}).Error; err != nil {
t.Fatalf("create history: %v", err)
}
envelope, err := svc.NextUp(t.Context(), user.ID, EncodeEmbyRemoteID(mount.ID, "series-100"), 1)
if err != nil {
t.Fatalf("NextUp: %v", err)
}
items, _ := envelope["Items"].([]map[string]any)
if len(items) != 1 {
t.Fatalf("items = %d, want 1 (%#v)", len(items), envelope)
}
wantID := EncodeEmbyRemoteID(mount.ID, "ep-2")
if id, _ := items[0]["Id"].(string); id != wantID {
t.Fatalf("Id = %q, want %q (未看完的那一集不能被跳过)", id, wantID)
}
userData, _ := items[0]["UserData"].(map[string]any)
if ticks, _ := userData["PlaybackPositionTicks"].(int64); ticks != 35820000 {
t.Fatalf("PlaybackPositionTicks = %#v, want 35820000 (详情页要能续播到 3.5 秒)", userData["PlaybackPositionTicks"])
}
}
// 远程剧集已看完当前一集时,NextUp 仍要指向下一集。
func TestMountedRemoteNextUpAfterCompletedEpisode(t *testing.T) {
episode := func(id string, index int) map[string]any {
return map[string]any{
"Id": id,
"Name": "第" + strconv.Itoa(index) + "集",
"Type": "Episode",
"SeriesId": "series-100",
"ParentIndexNumber": 1,
"IndexNumber": index,
"RunTimeTicks": 14400640000,
}
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/emby/Users/uid-1/Items/series-100":
_ = json.NewEncoder(w).Encode(map[string]any{"Id": "series-100", "Name": "剧一", "Type": "Series"})
case "/emby/Users/uid-1/Items/ep-3":
_ = json.NewEncoder(w).Encode(episode("ep-3", 3))
case "/emby/Users/uid-1/Items":
_ = json.NewEncoder(w).Encode(map[string]any{
"TotalRecordCount": 3,
"Items": []map[string]any{
episode("ep-1", 1),
episode("ep-2", 2),
episode("ep-3", 3),
},
})
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{}, &model.PlaybackHistory{}, &model.User{})
repos := repository.New(db)
cfg := &config.Config{}
remote := NewEmbyRemoteService(cfg, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
svc := NewEmbyService(cfg, zap.NewNop(), repos).SetEmbyRemote(remote)
rawConfig, _ := json.Marshal(map[string]string{
"url": server.URL,
"api_key": "test-api-key",
"remote_user_id": "uid-1",
})
acct := &model.StrmAccount{
Base: model.Base{ID: "acct-1"},
Name: "远程 Emby",
Provider: model.StrmProviderEmbyRemote,
Config: string(rawConfig),
Enabled: true,
}
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
t.Fatalf("create account: %v", err)
}
mount := &model.EmbyMount{
Base: model.Base{ID: "mount-1"},
AccountID: acct.ID,
RemoteViewID: "view-1",
RemoteViewName: "新番连载",
CollectionType: "tvshows",
Enabled: true,
}
if err := repos.EmbyMount.Create(t.Context(), mount); err != nil {
t.Fatalf("create mount: %v", err)
}
user := &model.User{
Base: model.Base{ID: "user-1"},
Username: "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)
}
// 第 2 集已整集看完。
if err := repos.DB.Create(&model.PlaybackHistory{
UserID: user.ID,
MediaID: EncodeEmbyRemoteID(mount.ID, "ep-2"),
PositionMs: 1440064,
DurationMs: 1440064,
WatchedAt: time.Now(),
Completed: true,
}).Error; err != nil {
t.Fatalf("create history: %v", err)
}
envelope, err := svc.NextUp(context.Background(), user.ID, EncodeEmbyRemoteID(mount.ID, "series-100"), 1)
if err != nil {
t.Fatalf("NextUp: %v", err)
}
items, _ := envelope["Items"].([]map[string]any)
if len(items) != 1 {
t.Fatalf("items = %d, want 1 (%#v)", len(items), envelope)
}
if id, _ := items[0]["Id"].(string); id != EncodeEmbyRemoteID(mount.ID, "ep-3") {
t.Fatalf("Id = %q, want ep-3", id)
}
}
+35 -18
View File
@@ -158,7 +158,7 @@ func (r *EmbyRemoteService) mapRemoteMountToLibrary(mount *model.EmbyMount, acct
}
// 远程媒体库封面只有真实存在图片标签才下发。
if remoteItemHasImageTag(item, "Primary") {
lib.CoverURL = r.remoteItemImageURL(cfg, mount.RemoteViewID, "Primary")
lib.CoverURL = r.remoteItemImageURL(acct, cfg, mount.RemoteViewID, "Primary")
}
return lib
}
@@ -176,6 +176,8 @@ func (r *EmbyRemoteService) MapRemoteItemToMedia(ctx context.Context, mount *mod
if _, rid, ok := DecodeEmbyRemoteID(remoteID); ok {
remoteID = rid
}
// 记录图片标签,使下发的图片 URL 带上 tag:远端换图后缓存随之失效。
r.rememberRemoteImageTags(embyRemoteAccountID(acct), item)
seriesID := remoteItemString(item, "SeriesId")
if _, rid, ok := DecodeEmbyRemoteID(seriesID); ok {
seriesID = rid
@@ -214,10 +216,10 @@ func (r *EmbyRemoteService) MapRemoteItemToMedia(ctx context.Context, mount *mod
}
// 只有远程明确存在图片标签才下发图片 URL。
if remoteItemHasImageTag(item, "Primary") {
media.PosterURL = r.remoteItemImageURL(cfg, remoteID, "Primary")
media.PosterURL = r.remoteItemImageURL(acct, cfg, remoteID, "Primary")
}
if remoteItemHasImageTag(item, "Backdrop") || len(remoteBackdropTags(item)) > 0 {
media.BackdropURL = r.remoteItemImageURL(cfg, remoteID, "Backdrop")
media.BackdropURL = r.remoteItemImageURL(acct, cfg, remoteID, "Backdrop")
}
if ticks := remoteItemInt64(item, "RunTimeTicks"); ticks > 0 {
media.DurationSec = int(ticks / 10_000_000)
@@ -307,9 +309,11 @@ func (r *EmbyRemoteService) MapRemoteItemToMedia(ctx context.Context, mount *mod
}
// 单集通常无独立海报:若远程返回 SeriesPrimaryImageTag(需要
// Fields=SeriesPrimaryImage)且系列有图,则回退到系列海报。
if media.PosterURL == "" && seriesID != "" &&
strings.TrimSpace(remoteItemString(item, "SeriesPrimaryImageTag")) != "" {
media.PosterURL = r.remoteItemImageURL(cfg, seriesID, "Primary")
if media.PosterURL == "" && seriesID != "" {
if seriesTag := strings.TrimSpace(remoteItemString(item, "SeriesPrimaryImageTag")); seriesTag != "" {
r.rememberRemoteImageTagValue(embyRemoteAccountID(acct), seriesID, "Primary", seriesTag)
media.PosterURL = r.remoteItemImageURL(acct, cfg, seriesID, "Primary")
}
}
default: // Movie / Series / Season / Folder
media.SeasonNum = 0
@@ -516,17 +520,17 @@ const remoteSeriesPageSize = 200
// RemoteSeriesCards 远程剧集库的系列卡片(ChildCount 作为集数)。
//
// 远程 Emby 的 Series DTO 不会返回 DateLastMediaAdded 字段(即使请求 Fields
// 也缺失),但其服务端排序支持 SortBy=DateLastContentAdded——即客户端"上次
// 添加集日期"排序。因此这里直接按该键倒序分页拉全量,返回的卡片顺序与对方
// Emby 客户端选择"上次添加集日期"完全一致;LastAddedAt 在远程提供字段时
// 才填充,否则保持 nil(前端对无该值的卡片维持服务器顺序,不再回退加入日期)。
// 与 Emby 客户端一致:IncludeItemTypes=Series + Recursive=true,按
// DateLastContentAdded 倒序分页拉全库剧集。多媒体根下的 anime/ 等中间容器
// 若偶发出现在结果里则过滤掉;LastAddedAt 仅在远程提供 DateLastMediaAdded
// 时填充。
func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteViewID string) ([]SeriesCard, error) {
cfg, err := r.remoteConfigWithToken(ctx, acct)
if err != nil {
return nil, err
}
cacheKey := r.remoteCacheKey("series-cards", acct.ID, mount.ID, remoteViewID)
// v3:Recursive=true(对齐 Emby 客户端),与旧直属/下探缓存区分。
cacheKey := r.remoteCacheKey("series-cards-v3", acct.ID, mount.ID, remoteViewID)
var cached []SeriesCard
if r.cache != nil && r.cache.GetJSON(ctx, cacheKey, &cached) {
return cached, nil
@@ -534,7 +538,7 @@ func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, mount *model.
q := url.Values{}
q.Set("ParentId", remoteViewID)
q.Set("IncludeItemTypes", "Series")
q.Set("Recursive", "false")
q.Set("Recursive", "true")
q.Set("SortBy", "DateLastContentAdded")
q.Set("SortOrder", "Descending")
q.Set("Limit", strconv.Itoa(remoteSeriesPageSize))
@@ -554,9 +558,13 @@ func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, mount *model.
break
}
for _, it := range body.Items {
name := remoteItemString(it, "Name")
path := remoteItemString(it, "Path")
if remoteSeriesItemLooksLikeContainer(name, path) {
continue
}
RewriteEmbyRemoteIDs(it, mount.ID)
m := r.MapRemoteItemToMedia(ctx, mount, acct, cfg, it)
// 集数优先用递归条目数(ChildCount 只算直属 Season 文件夹数)。
count := remoteItemInt(it, "RecursiveItemCount")
if count == 0 {
count = remoteItemInt(it, "ChildCount")
@@ -577,7 +585,7 @@ func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, mount *model.
LastAddedAt: lastAdded,
})
}
if int64(len(cards)) >= body.TotalRecordCount || len(body.Items) < remoteSeriesPageSize {
if int64(startIndex+len(body.Items)) >= body.TotalRecordCount || len(body.Items) < remoteSeriesPageSize {
break
}
}
@@ -587,6 +595,14 @@ func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, mount *model.
return cards, nil
}
func remoteSeriesItemLooksLikeContainer(name, path string) bool {
if isEmbyGenericContainer(name) {
return true
}
base := pathBaseSlash(strings.TrimRight(strings.ReplaceAll(path, "\\", "/"), "/"))
return base != "" && isEmbyGenericContainer(base)
}
// RemoteLatestCards 远程库最新条目(首页预览卡片),映射 SeriesCard。
func (r *EmbyRemoteService) RemoteLatestCards(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteViewID string, limit int) ([]SeriesCard, error) {
cfg, err := r.remoteConfigWithToken(ctx, acct)
@@ -902,8 +918,9 @@ func remoteItemGenres(item map[string]any) string {
return strings.Join(parts, ",")
}
// remoteItemImageURL 构造远程条目图片绝对地址(带 api_key;前端经 /api/img 代理)。
func (r *EmbyRemoteService) remoteItemImageURL(cfg *EmbyRemoteConfig, remoteID, imageType string) string {
// remoteItemImageURL 构造远程条目图片绝对地址(带 api_key 与图片 tag;前端经
// /api/img 代理)。tag 来自载荷的 ImageTags,用于远端换图后缓存失效。
func (r *EmbyRemoteService) remoteItemImageURL(acct *model.StrmAccount, cfg *EmbyRemoteConfig, remoteID, imageType string) string {
if remoteID == "" {
return ""
}
@@ -912,7 +929,7 @@ func (r *EmbyRemoteService) remoteItemImageURL(cfg *EmbyRemoteConfig, remoteID,
imageType = "primary"
}
return r.embyBase(cfg) + "/Items/" + url.PathEscape(remoteID) + "/Images/" + url.PathEscape(imageType) +
"?api_key=" + url.QueryEscape(cfg.Token)
"?api_key=" + url.QueryEscape(cfg.Token) + r.remoteImageTagQuery(embyRemoteAccountID(acct), remoteID, imageType)
}
// remoteItemHasImageTag 远程 item 是否带某类型图片标签(Emby 的 ImageTags map)。
+102 -1
View File
@@ -121,10 +121,15 @@ func TestRemoteSeriesCardsAutoAuthOnFirstBrowse(t *testing.T) {
}
if r.URL.Path == "/emby/Users/real-user-guid/Items" {
q := r.URL.Query()
if q.Get("ParentId") != "view-1" || q.Get("IncludeItemTypes") != "Series" {
if q.Get("IncludeItemTypes") != "Series" || q.Get("ParentId") != "view-1" {
w.WriteHeader(http.StatusBadRequest)
return
}
if q.Get("Recursive") != "true" {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte("expected Recursive=true"))
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"TotalRecordCount": 1,
@@ -231,6 +236,11 @@ func TestRemoteSeriesCardsResolveUserIDFromAPIKey(t *testing.T) {
return
}
if r.URL.Path == "/emby/Users/real-user-guid/Items" {
q := r.URL.Query()
if q.Get("ParentId") != "view-2" || q.Get("Recursive") != "true" {
w.WriteHeader(http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"TotalRecordCount": 1,
@@ -674,6 +684,11 @@ func TestRemoteSeriesCardsFetchAllPages(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
query := r.URL.Query()
if query.Get("Recursive") != "true" {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte("expected Recursive=true"))
return
}
startIndex, _ := strconv.Atoi(query.Get("StartIndex"))
limit, _ := strconv.Atoi(query.Get("Limit"))
requests.Add(1)
@@ -740,6 +755,84 @@ func TestRemoteSeriesCardsFetchAllPages(t *testing.T) {
}
}
// 与 Emby 客户端一致:Recursive=true 拉全库 Series,并过滤 anime 等中间容器。
func TestRemoteSeriesCardsRecursiveFiltersAnimeContainers(t *testing.T) {
var sawRecursive atomic.Bool
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if strings.TrimPrefix(r.URL.Path, "/emby") != "/Users/remote-user/Items" {
http.NotFound(w, r)
return
}
q := r.URL.Query()
if q.Get("ParentId") != "view-2023" || q.Get("IncludeItemTypes") != "Series" {
http.NotFound(w, r)
return
}
if q.Get("Recursive") == "true" {
sawRecursive.Store(true)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"TotalRecordCount": 5,
"Items": []map[string]any{
{"Id": "c0", "Name": "anime", "Type": "Series", "Path": "https://cdn/0/anime/", "RecursiveItemCount": 3086},
{"Id": "c1", "Name": "anime", "Type": "Series", "Path": "https://cdn/1/anime/", "RecursiveItemCount": 567},
{"Id": "s-a", "Name": "数码宝贝 BEATBREAK", "Type": "Series", "Path": "https://cdn/0/anime/digimon", "RecursiveItemCount": 24},
{"Id": "s-b", "Name": "活死喵之夜", "Type": "Series", "Path": "https://cdn/0/anime/nyaight", "RecursiveItemCount": 12},
{"Id": "s-c", "Name": "药屋少女的呢喃", "Type": "Series", "Path": "https://cdn/1/anime/kusuriya", "RecursiveItemCount": 51},
},
})
}))
defer server.Close()
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
repos := repository.New(db)
svc := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
rawConfig, _ := json.Marshal(map[string]string{
"url": server.URL,
"token": "fake-token",
"remote_user_id": "remote-user",
})
acct := &model.StrmAccount{
Base: model.Base{ID: "acct-recursive"},
Name: "nijigem",
Provider: model.StrmProviderEmbyRemote,
Config: string(rawConfig),
Enabled: true,
}
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
t.Fatalf("create account: %v", err)
}
mount := &model.EmbyMount{
Base: model.Base{ID: "mount-recursive"},
AccountID: acct.ID,
RemoteViewID: "view-2023",
RemoteViewName: "2023前 动漫",
CollectionType: "tvshows",
Enabled: true,
}
if err := repos.EmbyMount.Create(t.Context(), mount); err != nil {
t.Fatalf("create mount: %v", err)
}
cards, err := svc.RemoteSeriesCards(t.Context(), mount, acct, mount.RemoteViewID)
if err != nil {
t.Fatalf("RemoteSeriesCards failed: %v", err)
}
if !sawRecursive.Load() {
t.Fatal("expected Recursive=true on remote Items request")
}
if len(cards) != 3 {
t.Fatalf("cards = %d, want 3 real series after filtering anime containers", len(cards))
}
for _, card := range cards {
if strings.EqualFold(card.Rep.Title, "anime") {
t.Fatalf("container title %q should have been filtered", card.Rep.Title)
}
}
}
// 首页「最新条目」卡片 key 必须与媒体库剧集列表的 key 一致,否则点击后
// 媒体库页找不到目标剧集,只能退回整库列表。
func TestRemoteLatestCardsKeyMatchesSeriesList(t *testing.T) {
@@ -751,6 +844,14 @@ func TestRemoteLatestCardsKeyMatchesSeriesList(t *testing.T) {
return
}
seriesQueryCalls.Add(1)
parentID := r.URL.Query().Get("ParentId")
if parentID == "series-latest" {
_ = json.NewEncoder(w).Encode(map[string]any{
"Items": []map[string]any{},
"TotalRecordCount": 0,
})
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"Items": []map[string]any{
{
+231
View File
@@ -0,0 +1,231 @@
package service
import (
"testing"
"github.com/truewhile/MeBox/internal/model"
)
// seasonIndexFixture builds one series covering every season-numbering class a
// client can ask for: two regular seasons, a generic specials bucket (season 0)
// and an OVA folder (negative season).
func seasonIndexFixture(t *testing.T) (*EmbyService, string) {
t.Helper()
svc := newTestEmbyService(t)
lib := model.Library{Name: "剧集", Path: `F:\media\剧集`, Type: "tv", Enabled: true}
if err := svc.repo.Library.Create(t.Context(), &lib); err != nil {
t.Fatalf("create library: %v", err)
}
rows := []model.Media{
{
Base: model.Base{ID: "s1e1"},
LibraryID: lib.ID,
Title: "權力的遊戲",
Path: `F:\media\剧集\權力的遊戲 (2011)\Season 01\權力的遊戲 - S01E01.mkv`,
SeasonNum: 1,
EpisodeNum: 1,
},
{
Base: model.Base{ID: "s1e2"},
LibraryID: lib.ID,
Title: "權力的遊戲",
Path: `F:\media\剧集\權力的遊戲 (2011)\Season 01\權力的遊戲 - S01E02.mkv`,
SeasonNum: 1,
EpisodeNum: 2,
},
{
Base: model.Base{ID: "s2e1"},
LibraryID: lib.ID,
Title: "權力的遊戲",
Path: `F:\media\剧集\權力的遊戲 (2011)\Season 02\權力的遊戲 - S02E01.mkv`,
SeasonNum: 2,
EpisodeNum: 1,
},
{
Base: model.Base{ID: "spec1"},
LibraryID: lib.ID,
Title: "權力的遊戲",
Path: `F:\media\剧集\權力的遊戲 (2011)\Specials\權力的遊戲 - S00E01.mkv`,
SeasonNum: 0,
EpisodeNum: 1,
},
{
Base: model.Base{ID: "ova1"},
LibraryID: lib.ID,
Title: "權力的遊戲",
Path: `F:\media\剧集\權力的遊戲 (2011)\OVA\權力的遊戲 - OVA.mkv`,
SeasonNum: 0,
EpisodeNum: 1,
},
}
for i := range rows {
if err := svc.repo.DB.Create(&rows[i]).Error; err != nil {
t.Fatalf("create media: %v", err)
}
}
items, err := svc.Items(t.Context(), ItemsParams{
ParentID: lib.ID,
IncludeItemTypes: []string{"Series"},
Limit: 10,
})
if err != nil {
t.Fatalf("series items: %v", err)
}
cards := items["Items"].([]map[string]any)
if len(cards) != 1 {
t.Fatalf("want one series card, got %#v", cards)
}
return svc, cards[0]["Id"].(string)
}
func episodeIDs(t *testing.T, out map[string]any) []string {
t.Helper()
items, ok := out["Items"].([]map[string]any)
if !ok {
t.Fatalf("Items has unexpected type: %#v", out["Items"])
}
ids := make([]string, 0, len(items))
for _, item := range items {
ids = append(ids, item["Id"].(string))
}
return ids
}
// itemsTotal reads TotalRecordCount, which is int on the episode path and int64
// on the empty-envelope path.
func itemsTotal(t *testing.T, out map[string]any) int {
t.Helper()
switch v := out["TotalRecordCount"].(type) {
case int:
return v
case int64:
return int(v)
default:
t.Fatalf("TotalRecordCount has unexpected type: %#v", out["TotalRecordCount"])
return 0
}
}
func assertEpisodeIDs(t *testing.T, got, want []string) {
t.Helper()
if len(got) != len(want) {
t.Fatalf("episode ids = %#v, want %#v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("episode ids = %#v, want %#v", got, want)
}
}
}
// TestEmbyEpisodeItemsFilterBySeasonIndex is the regression guard for clients
// that scope episodes by season number (Season=N) instead of the virtual season
// id: without the filter every season was returned for any requested season.
func TestEmbyEpisodeItemsFilterBySeasonIndex(t *testing.T) {
svc, seriesID := seasonIndexFixture(t)
cases := []struct {
name string
season *int
wantIDs []string
wantTotal int
}{
{
name: "unfiltered returns every episode",
season: nil,
// episodeItems sorts by the stored season/episode numbers, so the two
// specials (both stored as season 0) come first in creation order.
wantIDs: []string{"spec1", "ova1", "s1e1", "s1e2", "s2e1"},
wantTotal: 5,
},
{name: "season 1", season: intPtr(1), wantIDs: []string{"s1e1", "s1e2"}, wantTotal: 2},
{name: "season 2", season: intPtr(2), wantIDs: []string{"s2e1"}, wantTotal: 1},
{name: "season 0 is the specials bucket", season: intPtr(0), wantIDs: []string{"spec1"}, wantTotal: 1},
{name: "OVA season", season: intPtr(embySeasonOVA), wantIDs: []string{"ova1"}, wantTotal: 1},
{name: "unknown season is empty", season: intPtr(9), wantIDs: []string{}, wantTotal: 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
out, err := svc.Items(t.Context(), ItemsParams{
ParentID: seriesID,
IncludeItemTypes: []string{"Episode"},
Recursive: true,
Limit: 50,
SeasonIndex: tc.season,
})
if err != nil {
t.Fatalf("items: %v", err)
}
assertEpisodeIDs(t, episodeIDs(t, out), tc.wantIDs)
if total := itemsTotal(t, out); total != tc.wantTotal {
t.Fatalf("TotalRecordCount = %d, want %d", total, tc.wantTotal)
}
})
}
}
// TestEmbySeasonIndexFilterKeepsSeasonIdsWorking pins the pre-existing
// SeasonId behaviour: resolving through the virtual season id must still return
// exactly that season.
func TestEmbySeasonIndexFilterKeepsSeasonIdsWorking(t *testing.T) {
svc, seriesID := seasonIndexFixture(t)
items, err := svc.Items(t.Context(), ItemsParams{ParentID: seriesID, Limit: 50})
if err != nil {
t.Fatalf("seasons: %v", err)
}
seasons := items["Items"].([]map[string]any)
var seasonTwoID string
for _, season := range seasons {
if season["IndexNumber"] == 2 {
seasonTwoID = season["Id"].(string)
}
}
if seasonTwoID == "" {
t.Fatalf("season 2 missing from payload: %#v", seasons)
}
out, err := svc.Items(t.Context(), ItemsParams{
ParentID: seasonTwoID,
IncludeItemTypes: []string{"Episode"},
Recursive: true,
Limit: 50,
})
if err != nil {
t.Fatalf("season episodes: %v", err)
}
got := episodeIDs(t, out)
assertEpisodeIDs(t, got, []string{"s2e1"})
}
// TestEmbyItemsCacheKeySeparatesSeasonFilters guards against a cached unfiltered
// response being reused for a season-scoped request (and vice versa).
func TestEmbyItemsCacheKeySeparatesSeasonFilters(t *testing.T) {
svc := newTestEmbyService(t)
base := ItemsParams{ParentID: "series-1", IncludeItemTypes: []string{"Episode"}, Limit: 50}
unfiltered := svc.embyItemsCacheKey("items", base)
seasonZero := svc.embyItemsCacheKey("items", withSeasonIndex(base, 0))
seasonTwo := svc.embyItemsCacheKey("items", withSeasonIndex(base, 2))
if unfiltered == seasonZero {
t.Fatal("unfiltered and Season=0 keys must differ")
}
if unfiltered == seasonTwo {
t.Fatal("unfiltered and Season=2 keys must differ")
}
if seasonZero == seasonTwo {
t.Fatal("Season=0 and Season=2 keys must differ")
}
if again := svc.embyItemsCacheKey("items", withSeasonIndex(base, 2)); again != seasonTwo {
t.Fatal("cache key must be stable for the same season filter")
}
}
func withSeasonIndex(p ItemsParams, season int) ItemsParams {
p.SeasonIndex = intPtr(season)
return p
}
func intPtr(v int) *int { return &v }
+39 -36
View File
@@ -55,7 +55,7 @@ func (e *EmbyService) findSeriesGroup(ctx context.Context, id, userID string) (e
// 有 series_id 时分组 key 就是 series_id 本身(UUID,不带虚拟前缀)。
q = q.Where("series_id IS NULL OR series_id = ''")
}
if err := q.Order("media.season_num asc, media.episode_num asc, media.created_at asc").Limit(embySeriesGroupingLimit).Find(&rows).Error; err != nil {
if err := q.Order("media.season_num asc, media.episode_num asc, media.episode_fraction asc, media.created_at asc").Limit(embySeriesGroupingLimit).Find(&rows).Error; err != nil {
return embySeriesGroup{}, false, err
}
for _, group := range e.seriesGroupsFromMedia(ctx, rows) {
@@ -68,20 +68,20 @@ func (e *EmbyService) findSeriesGroup(ctx context.Context, id, userID string) (e
if series, err := e.repo.Series.FindByID(ctx, id); err != nil {
return embySeriesGroup{}, false, err
} else if series != nil {
return embySeriesGroup{
ID: series.ID,
LibraryID: series.LibraryID,
Name: series.Title,
PosterURL: series.PosterURL,
BackdropURL: series.BackdropURL,
Overview: series.Overview,
Rating: series.Rating,
Year: series.Year,
TMDbID: series.TMDbID,
BangumiID: series.BangumiID,
CreatedAt: series.CreatedAt,
DateLastMediaAdded: series.CreatedAt,
}, true, nil
return embySeriesGroup{
ID: series.ID,
LibraryID: series.LibraryID,
Name: series.Title,
PosterURL: series.PosterURL,
BackdropURL: series.BackdropURL,
Overview: series.Overview,
Rating: series.Rating,
Year: series.Year,
TMDbID: series.TMDbID,
BangumiID: series.BangumiID,
CreatedAt: series.CreatedAt,
DateLastMediaAdded: series.CreatedAt,
}, true, nil
}
}
return embySeriesGroup{}, false, nil
@@ -108,7 +108,7 @@ func (e *EmbyService) findSeasonGroup(ctx context.Context, id, userID string) (e
Where("(series_id IS NULL OR series_id = '') AND (season_num > 0 OR episode_num > 0)")
q = e.applyUserMediaVisibility(ctx, q, userID)
if err := q.
Order("media.season_num asc, media.episode_num asc, media.created_at asc").
Order("media.season_num asc, media.episode_num asc, media.episode_fraction asc, media.created_at asc").
Limit(embySeriesGroupingLimit).
Find(&rows).Error; err != nil {
return embySeasonGroup{}, false, err
@@ -167,7 +167,7 @@ func (e *EmbyService) seasonGroupForSeries(ctx context.Context, id, seriesID, us
Where("series_id = ? AND (season_num > 0 OR episode_num > 0)", seriesID)
rq = e.applyUserMediaVisibility(ctx, rq, userID)
if err := rq.
Order("media.season_num asc, media.episode_num asc, media.created_at asc").
Order("media.season_num asc, media.episode_num asc, media.episode_fraction asc, media.created_at asc").
Limit(embySeriesGroupingLimit).
Find(&rows).Error; err != nil {
return embySeasonGroup{}, false, err
@@ -194,26 +194,26 @@ func (e *EmbyService) seriesGroupsFromMedia(ctx context.Context, rows []model.Me
seriesID := e.seriesIDForMedia(ctx, &row)
group, ok := byID[seriesID]
if !ok {
group = &embySeriesGroup{
ID: seriesID,
LibraryID: row.LibraryID,
Name: e.seriesNameForMedia(ctx, &row),
Year: row.Year,
ReleaseDate: row.ReleaseDate,
TMDbID: row.TMDbID,
BangumiID: row.BangumiID,
CreatedAt: row.CreatedAt,
DateLastMediaAdded: row.CreatedAt,
}
byID[seriesID] = group
order = append(order, seriesID)
}
if row.CreatedAt.Before(group.CreatedAt) || group.CreatedAt.IsZero() {
group.CreatedAt = row.CreatedAt
}
if row.CreatedAt.After(group.DateLastMediaAdded) {
group.DateLastMediaAdded = row.CreatedAt
group = &embySeriesGroup{
ID: seriesID,
LibraryID: row.LibraryID,
Name: e.seriesNameForMedia(ctx, &row),
Year: row.Year,
ReleaseDate: row.ReleaseDate,
TMDbID: row.TMDbID,
BangumiID: row.BangumiID,
CreatedAt: row.CreatedAt,
DateLastMediaAdded: row.CreatedAt,
}
byID[seriesID] = group
order = append(order, seriesID)
}
if row.CreatedAt.Before(group.CreatedAt) || group.CreatedAt.IsZero() {
group.CreatedAt = row.CreatedAt
}
if row.CreatedAt.After(group.DateLastMediaAdded) {
group.DateLastMediaAdded = row.CreatedAt
}
if strings.TrimSpace(row.ReleaseDate) != "" && mediaReleaseSortTime(row).After(embySeriesReleaseSortTime(*group)) {
group.ReleaseDate = row.ReleaseDate
if row.Year > 0 {
@@ -249,6 +249,9 @@ func (e *EmbyService) seriesGroupsFromMedia(ctx context.Context, rows []model.Me
if group.Episodes[i].EpisodeNum != group.Episodes[j].EpisodeNum {
return group.Episodes[i].EpisodeNum < group.Episodes[j].EpisodeNum
}
if group.Episodes[i].EpisodeFraction != group.Episodes[j].EpisodeFraction {
return group.Episodes[i].EpisodeFraction < group.Episodes[j].EpisodeFraction
}
return group.Episodes[i].CreatedAt.Before(group.Episodes[j].CreatedAt)
})
groups = append(groups, group)
+10
View File
@@ -154,6 +154,16 @@ func embySeasonNumForMedia(m *model.Media) int {
return m.SeasonNum
}
// embyRowMatchesSeasonIndex 判断某条剧集是否属于客户端请求的季序号。
// seasonIndex 为 nil 时不参与过滤。判定复用 embySeasonNumForMedia,保证与
// /Shows/{id}/Seasons 暴露的 IndexNumber 完全一致(含特别篇的负数分类)。
func embyRowMatchesSeasonIndex(m *model.Media, seasonIndex *int) bool {
if seasonIndex == nil {
return true
}
return embySeasonNumForMedia(m) == *seasonIndex
}
func embySeasonCandidates(seasonNum int) []int {
if seasonNum > 0 {
return []int{seasonNum}
+118
View File
@@ -0,0 +1,118 @@
package service
import (
"context"
"testing"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
)
// totalRecordCount 兼容两种来源:直算出来的是 int64,经过进程内缓存 JSON
// 往返后是 float64。两者序列化成 Emby 响应时完全一致。
func totalRecordCount(t *testing.T, out map[string]any) int {
t.Helper()
switch v := out["TotalRecordCount"].(type) {
case int64:
return int(v)
case int:
return v
case float64:
return int(v)
default:
t.Fatalf("unexpected TotalRecordCount type %T (%v)", out["TotalRecordCount"], out["TotalRecordCount"])
return 0
}
}
// 相似推荐的结果要缓存:详情页每次打开都会请求它,重建要走「取候选池 + 内存打分」
// (实测冷 340ms / 热 70ms)。
func TestSimilarItemsServesCachedPayload(t *testing.T) {
svc := newTestEmbyService(t)
repos := svc.repo
svc.SetRuntimeCache(NewRuntimeCacheService(&config.Config{}, zap.NewNop()))
libID := seedDiscoveryLibrary(t, repos, "movie")
source := seedSimilarMedia(t, repos, libID, "源片", "Action", 2010, 7)
seedSimilarMedia(t, repos, libID, "候选甲", "Action", 2010, 7)
seedSimilarMedia(t, repos, libID, "候选乙", "Comedy", 2011, 6)
ctx := context.Background()
first, err := svc.SimilarItems(ctx, source.ID, "user-1", 10)
if err != nil {
t.Fatalf("first SimilarItems: %v", err)
}
want := totalRecordCount(t, first)
if want == 0 {
t.Fatalf("first call returned no candidates, test data is wrong: %+v", first)
}
// 把候选全部删掉:第二次如果还返回原结果,只可能是命中缓存。
if err := repos.DB.Where("id <> ?", source.ID).Delete(&model.Media{}).Error; err != nil {
t.Fatal(err)
}
second, err := svc.SimilarItems(ctx, source.ID, "user-1", 10)
if err != nil {
t.Fatalf("second SimilarItems: %v", err)
}
if got := totalRecordCount(t, second); got != want {
t.Fatalf("cached call returned %d items, want %d (cache miss?)", got, want)
}
// 另一个用户(不同的可见性)不能复用别人的缓存:这里应当重新查询并得到 0。
other, err := svc.SimilarItems(ctx, source.ID, "user-2", 10)
if err != nil {
t.Fatalf("other user SimilarItems: %v", err)
}
if got := totalRecordCount(t, other); got != 0 {
t.Fatalf("other user got %d items, want 0 (per-user cache key)", got)
}
}
// limit 不同必须分开缓存,否则一次小 limit 请求会污染后续更大的请求。
func TestSimilarItemsCacheSeparatesLimit(t *testing.T) {
svc := newTestEmbyService(t)
repos := svc.repo
svc.SetRuntimeCache(NewRuntimeCacheService(&config.Config{}, zap.NewNop()))
libID := seedDiscoveryLibrary(t, repos, "movie")
source := seedSimilarMedia(t, repos, libID, "源片", "Action", 2010, 7)
for _, title := range []string{"甲", "乙", "丙", "丁"} {
seedSimilarMedia(t, repos, libID, "候选"+title, "Action", 2010, 7)
}
ctx := context.Background()
small, err := svc.SimilarItems(ctx, source.ID, "user-1", 2)
if err != nil {
t.Fatal(err)
}
if got := totalRecordCount(t, small); got != 2 {
t.Fatalf("limit=2 returned %d items, want 2", got)
}
large, err := svc.SimilarItems(ctx, source.ID, "user-1", 4)
if err != nil {
t.Fatal(err)
}
if got := totalRecordCount(t, large); got != 4 {
t.Fatalf("limit=4 returned %d items, want 4 (limit must be part of the cache key)", got)
}
}
// 没有注入缓存时(测试/精简部署)也必须正常工作。
func TestSimilarItemsWithoutCacheStillWorks(t *testing.T) {
svc := newTestEmbyService(t)
libID := seedDiscoveryLibrary(t, svc.repo, "movie")
source := seedSimilarMedia(t, svc.repo, libID, "源片", "Action", 2010, 7)
seedSimilarMedia(t, svc.repo, libID, "候选甲", "Action", 2010, 7)
out, err := svc.SimilarItems(context.Background(), source.ID, "user-1", 10)
if err != nil {
t.Fatalf("SimilarItems: %v", err)
}
if got := totalRecordCount(t, out); got == 0 {
t.Fatalf("expected candidates without a cache, got %+v", out)
}
}

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