Compare commits

...

18 Commits

Author SHA1 Message Date
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
122 changed files with 8552 additions and 1298 deletions
+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
+743
View File
@@ -0,0 +1,743 @@
# 阅读模块(Reading Module)设计与实施方案
> 状态:设计稿,待评审
> 目标版本:v0.2.0(分期落地,见第 9 节)
> 关联现有子系统:媒体库 / 网盘存储 / 权限体系 / 任务队列
---
## 1. 目标与范围
### 1.1 已确认的产品决策
| 维度 | 决策 |
| --- | --- |
| 内容类型 | **电子书 + 漫画统一书架**(EPUB / TXT / PDF / MOBI 与 CBZ / CBR / 图片文件夹) |
| 书源 | **独立书库**(不复用影视媒体库)+ **网盘直链阅读** |
| 首版范围 | **完整版**:多用户书库权限 + 阅读统计 |
| 阅读形态 | **滚动流式与分页翻页双模式**,用户可切换并持久化偏好 |
### 1.2 明确的非目标
- **不接入 Emby/Jellyfin 协议。** Emby 的 `Items` / `Views` / `PlaybackInfo` 语义围绕音视频构建,没有书籍章节与阅读进度的对应概念。强行映射会污染 `internal/service/emby_*.go` 与 `internal/handler/emby_*.go` 的既有兼容层,收益极低。阅读能力只通过 MeBox 自己的 Web UI 提供。
- **不复用 `model.Library` / `LibraryRoot`。** `Library.Type` 的取值域是 `movie/tv/anime/music`,且被海报墙轮播(`CarouselEnabled`)、自动整理管线、Emby 视图、首页预览等链路消费。把书库塞进去会导致这些链路需要到处加 `type != "book"` 判断。
- 首版不做:听书 TTS、在线书源(笔趣阁类)、社交分享、跨设备同步批注冲突合并。
---
## 2. 总体架构
### 2.1 分层落位
完全沿用现有分层,不引入新模式:
```
web/src/pages/Books*.tsx ← 页面
web/src/components/Book*.tsx ← 阅读器与书架组件
web/src/api/books.ts ← axios 封装(仿 web/src/api/library.ts)
↓ /api/books/*
internal/handler/books*.go ← 反序列化 + 权限校验 + 响应
internal/service/book_*.go ← 业务策略(扫描、解析、进度、统计)
internal/repository/book_*.go ← 纯持久化
internal/model/book.go ← GORM 模型,注册进 model.AllModels()
```
新增路由注册走 `internal/handler/routes_authenticated_features.go` 的既有范式,新增一个 `registerAuthedBookRoutes(authed, svc)`,在 `registerAuthenticatedRoutes` 链上挂载。`service.Container` 与 `repository.Container` 各追加一个字段。
### 2.2 与现有能力的复用点
| 现有部件 | 复用方式 |
| --- | --- |
| `service.StreamService.ServeFile`(`internal/service/stream_file.go`) | 已用 `http.ServeContent` 处理 HEAD / Range / If-Modified-Since,**PDF 与原始文件流直接照搬这条路径** |
| `cloud.Provider.Resolve(ctx, fileRef) (*DirectLink, error)`(`internal/service/cloud/cloud.go`) | 网盘书源的直链解析入口,`DirectLink.Proxy` 决定 302 还是反代 |
| `model.StorageConfig`(`internal/model/storage_assistant.go`) | 直接复用为网盘书源的账号凭据载体,**不新建凭据表** |
| `service.ImageProxy`(`internal/service/image_proxy*.go`) | 漫画页与封面的磁盘缓存 + 远程拉取 + 缩放,复用其缓存目录与命名思路 |
| `service.PruneImageCache` / `PruneImageCachePools`(`internal/service/cache_cleanup.go`) | 现成的「按池做 LRU 淘汰 + 按保留时长淘汰」助手,书籍缓存淘汰直接复用它 |
| `service/scheduler_local_jobs.go` | 本地定时任务的挂载点,书籍缓存清理与每日统计汇总都注册在这里 |
| `config.CacheConfig`(`internal/config/types.go`) | 已有 `CacheDir` / `ImagesMaxSizeMB` / `ImagesOriginalsMaxSizeMB` / `ImagesOriginalsTTLHours` / `MemoryMaxSizeMB`,书籍缓存容量配置直接挂进去 |
| `service.FileManager`(`internal/service/filemanager.go`) | 本地书源目录浏览,前端复用 `LocalDirBrowserDialog.tsx` |
| `service.Scheduler` | 书库定时扫描(默认关闭,管理员可开) |
| `model.UserPermission` | 新增阅读权限位,见第 7 节 |
| `helper.Go` / `Container.stopCtx` | 后台扫描任务的生命周期管理 |
---
## 3. 数据模型
新增文件 `internal/model/book.go`,并在 `internal/model/model.go` 的 `AllModels()` 中追加。所有表继承 `model.Base`(UUID 主键 + 时间戳 + 软删除)。
**表名约定**:`internal/model` 全包**没有任何 `TableName()` 覆盖**,一律使用 GORM 默认复数化(例如 `PlaybackHistory` → `playback_histories`,可从 `internal/database/schema_migration.go` 的裸 SQL 印证)。新表沿用该约定,不引入例外。因此模型命名要保证复数化结果干净:
| 模型 | 表名 |
| --- | --- |
| `Book` | `books` |
| `BookLibrary` | `book_libraries` |
| `BookSource` | `book_sources` |
| `BookChapter` | `book_chapters` |
| `BookProgress` | `book_progresses` |
| `BookAnnotation` | `book_annotations` |
| `BookFavorite` | `book_favorites` |
| `BookReadingSession` | `book_reading_sessions` |
| `BookDailyStat` | `book_daily_stats` |
(刻意用 `BookDailyStat` 而不是 `BookStatDaily`——后者复数化会得到 `book_stat_dailies`。)
### 3.1 书库与书源
```go
// BookLibrary 是独立于影视媒体库的书库。
type BookLibrary struct {
Base
Name string `gorm:"size:128;not null" json:"name"`
Kind string `gorm:"size:16;not null;default:mixed" json:"kind"` // ebook / comic / mixed
CoverURL string `gorm:"size:1024" json:"cover_url,omitempty"`
Enabled bool `gorm:"default:true" json:"enabled"`
SortOrder int `gorm:"index;default:0" json:"sort_order"`
LastScanAt *time.Time `json:"last_scan_at,omitempty"`
ScanStatus string `gorm:"size:16;default:idle" json:"scan_status"` // idle / scanning / error
ScanMessage string `gorm:"size:512" json:"scan_message,omitempty"`
}
// BookSource 是书库下的一条挂载来源:本地目录或网盘路径。
type BookSource struct {
Base
LibraryID string `gorm:"index;size:36;not null" json:"library_id"`
Name string `gorm:"size:128" json:"name,omitempty"`
StorageKind string `gorm:"size:16;not null;default:local" json:"storage_kind"` // local / cloud
Path string `gorm:"size:1024;not null" json:"path"` // 本地绝对路径 / 网盘内路径
StorageConfigID string `gorm:"index;size:36" json:"storage_config_id,omitempty"` // 复用 model.StorageConfig
Depth int `gorm:"default:3" json:"depth"` // 扫描递归深度上限
Enabled bool `gorm:"default:true" json:"enabled"`
SortOrder int `gorm:"default:0" json:"sort_order"`
}
```
`StorageKind = cloud` 时,`StorageConfigID` 指向一条 `StorageConfig`(`Type` ∈ `cloud115 / clouddrive2 / openlist / emby_remote`)。凭据解密沿用 `service.CryptoService`。
### 3.2 书籍与章节
```go
type Book struct {
Base
LibraryID string `gorm:"index;size:36;not null" json:"library_id"`
SourceID string `gorm:"uniqueIndex:uniq_book_source_path,priority:1;index;size:36;not null" json:"source_id"`
// SourcePath 在本地源是绝对路径,在网盘源是「网盘内路径」,两者都用
// (source_id, source_path) 做唯一键,天然隔离两个 ID 空间。
SourcePath string `gorm:"uniqueIndex:uniq_book_source_path,priority:2;size:1024;not null" json:"source_path"`
SourceRef string `gorm:"size:256" json:"source_ref,omitempty"` // 网盘 file id / pickcode
Title string `gorm:"size:512;not null" json:"title"`
Author string `gorm:"size:256;index" json:"author,omitempty"`
SeriesName string `gorm:"size:256;index" json:"series_name,omitempty"`
Volume int `json:"volume"`
Format string `gorm:"size:16;not null" json:"format"` // epub/txt/pdf/mobi/cbz/cbr/folder
MediaKind string `gorm:"size:16;not null;default:ebook" json:"media_kind"` // ebook / comic
SizeBytes int64 `json:"size_bytes"`
FileHash string `gorm:"index;size:64" json:"file_hash,omitempty"` // 大小+首尾采样,去重
CoverURL string `gorm:"size:1024" json:"cover_url,omitempty"`
Description string `gorm:"type:text" json:"description,omitempty"`
Language string `gorm:"size:32" json:"language,omitempty"`
Tags string `gorm:"type:text" json:"tags,omitempty"` // 逗号分隔
ChapterCount int `json:"chapter_count"`
WordCount int64 `json:"word_count"`
PageCount int `json:"page_count"` // 漫画总页数 / PDF 页数
ParseStatus string `gorm:"size:16;default:pending" json:"parse_status"` // pending/ok/failed
ParseMessage string `gorm:"size:512" json:"parse_message,omitempty"`
NSFW bool `gorm:"default:false" json:"nsfw"`
AddedAt time.Time `json:"added_at"`
}
```
**唯一键说明**:`SourcePath` 上的 `uniqueIndex` 需与 `SourceID` 组成复合键(`uniq_book_source_path`,priority 1 = `source_id`)。同一本书被两个书源包含时允许重复入库,这是符合预期的(用户可能故意如此)。
```go
// BookChapter 只存索引,不存正文(见 3.4 的取舍)。
type BookChapter struct {
Base
BookID string `gorm:"index:idx_book_chapter,priority:1;size:36;not null" json:"book_id"`
Index int `gorm:"index:idx_book_chapter,priority:2" json:"index"`
Title string `gorm:"size:512" json:"title"`
Level int `gorm:"default:1" json:"level"` // 目录嵌套层级,1 = 顶级
// 电子书定位:二选一
Href string `gorm:"size:1024" json:"href,omitempty"` // EPUB zip 内条目路径
StartOffset int64 `json:"start_offset"` // TXT 字节区间
EndOffset int64 `json:"end_offset"`
// 漫画/PDF 定位
PageStart int `json:"page_start"`
PageEnd int `json:"page_end"`
CharCount int `json:"char_count"`
}
```
### 3.3 进度、批注、收藏、统计
```go
// BookProgress 每个用户每本书一行(复合唯一键,仿 model.PlaybackHistory 的 uniq_user_history 模式)。
type BookProgress struct {
Base
UserID string `gorm:"uniqueIndex:uniq_user_book,priority:1;size:36;not null" json:"user_id"`
BookID string `gorm:"uniqueIndex:uniq_user_book,priority:2;size:36;not null" json:"book_id"`
ChapterIndex int `gorm:"default:0" json:"chapter_index"`
ChapterTitle string `gorm:"size:512" json:"chapter_title,omitempty"`
CharOffset int `json:"char_offset"` // 章内字符偏移(电子书)
PageIndex int `json:"page_index"` // 页码(漫画 / PDF)
Percent float64 `json:"percent"` // 全书百分比,书架进度条展示用
ScrollRatio float64 `json:"scroll_ratio"` // 章内滚动比例,跨端还原更精确
ReaderMode string `gorm:"size:16;default:scroll" json:"reader_mode"` // scroll / paged
Finished bool `gorm:"default:false" json:"finished"`
TotalSeconds int64 `json:"total_seconds"`
LastReadAt time.Time `gorm:"index" json:"last_read_at"`
}
type BookAnnotation struct {
Base
UserID string `gorm:"index:idx_book_anno,priority:1;size:36;not null" json:"user_id"`
BookID string `gorm:"index:idx_book_anno,priority:2;size:36;not null" json:"book_id"`
ChapterIndex int `json:"chapter_index"`
Type string `gorm:"size:16;not null" json:"type"` // bookmark / highlight / note
StartOffset int `json:"start_offset"`
EndOffset int `json:"end_offset"`
SelectedText string `gorm:"size:2048" json:"selected_text,omitempty"`
Note string `gorm:"type:text" json:"note,omitempty"`
Color string `gorm:"size:16" json:"color,omitempty"`
}
type BookFavorite struct {
Base
UserID string `gorm:"uniqueIndex:uniq_user_book_fav,priority:1;size:36;not null" json:"user_id"`
BookID string `gorm:"uniqueIndex:uniq_user_book_fav,priority:2;size:36;not null" json:"book_id"`
}
// BookReadingSession 由前端心跳驱动,服务端按小时聚合,避免行数爆炸。
type BookReadingSession struct {
Base
UserID string `gorm:"index:idx_book_stat,priority:1;size:36;not null" json:"user_id"`
BookID string `gorm:"index;size:36;not null" json:"book_id"`
BucketStart time.Time `gorm:"index:idx_book_stat,priority:2" json:"bucket_start"` // 截断到小时
Seconds int64 `json:"seconds"`
CharsRead int64 `json:"chars_read"`
PagesRead int `json:"pages_read"`
}
// BookDailyStat 每日汇总,供热力图与「年度阅读报告」查询,避免实时扫 session 表。
type BookDailyStat struct {
Base
UserID string `gorm:"uniqueIndex:uniq_user_book_daily,priority:1;size:36;not null" json:"user_id"`
Day string `gorm:"uniqueIndex:uniq_user_book_daily,priority:2;size:10;not null" json:"day"` // YYYY-MM-DD
Seconds int64 `json:"seconds"`
Chars int64 `json:"chars"`
Pages int `json:"pages"`
Books int `json:"books"` // 当日有阅读记录的书数
}
```
### 3.4 关键取舍:正文不入库
**决策:DB 只存章节索引(偏移量 / zip 内路径 / 页码区间),正文按需从源文件读取。**
理由:
1. 网文 TXT 常见 5–50MB,漫画单册 100–800MB。入库会让 SQLite 单文件膨胀到数十 GB,直接冲击 `docker-compose.simple.yml` 的「单文件数据库好备份」定位,也会拖慢全库 VACUUM / 备份 / 数据库迁移(`internal/service/database_admin.go`)。
2. 源文件本来就是权威副本,重复存储没有收益。
3. EPUB 与 CBZ 本质上都是 zip,**随机读取 zip 内单个条目成本极低**(读中央目录 + 解压目标条目),不需要把整本解压落盘。
代价是每次打开章节都要读源文件。缓解手段:
- 本地源:`os.Open` + `io.SectionReader`,代价可忽略。
- 网盘源:见 4.3 的本地缓存策略,且对已缓存的章节走本地。
### 3.5 用户级字段(挂在 `model.User` 上)
沿用 `AllowedLibraryIDs` 的 JSON-in-text 模式(见 `internal/model/user.go`),**不复用影视库字段**,避免两个 ID 空间交叉:
```go
// 追加到 model.User
ReaderSettings string `gorm:"type:text" json:"-"` // 阅读器偏好 JSON
AllowedBookLibraryIDs string `gorm:"type:text" json:"-"` // 空 = 不限制
AllowedBookLibraryList []string `gorm:"-" json:"allowed_book_library_ids,omitempty"`
```
`ReaderSettings` 结构(前端读写,服务端仅透传与长度校验):
```json
{
"mode": "scroll|paged",
"fontSize": 18,
"lineHeight": 1.8,
"fontFamily": "serif|sans|custom",
"contentWidth": 720,
"theme": "light|sepia|dark|black",
"pageAnimation": "slide|fade|none",
"comicLayout": "single|double|auto",
"comicDirection": "ltr|rtl",
"hideScrollbar": true
}
```
放在 `User` 行内(而非新表)的理由:与 `PlayerVolume` / `DanmakuFontSize` 等既有播放器偏好一致,读取时随用户信息一并返回,无需额外查询。
---
## 4. 书源与内容读取管线
### 4.1 扫描流程
```
POST /api/books/libraries/:id/scan
→ BookScannerService.ScanLibrary(ctx, libraryID)
1. 置 ScanStatus=scanning,通过 SSEHub 广播进度(复用 service.SSEHub)
2. 遍历启用的 BookSource
- local: filepath.WalkDir,按扩展名白名单过滤,超过 Depth 停止递归
- cloud: cloud.New(cfg.Type, cfg, client).List(ctx, dirID) 递归列目录
3. 对每个候选文件调 BookParser.ParseMeta(reader) 拿元信息 + 目录
4. Upsert 到 books / book_chapters(source_id + source_path 为幂等键)
5. 源上已消失的书标记软删除(与影视库扫描语义保持一致)
6. 置 ScanStatus=idle,记录 LastScanAt
```
扩展名白名单:`.epub .txt .pdf .mobi .azw3 .cbz .cbr .zip .rar`(`.zip/.rar` 仅当目录内全是图片时按漫画处理,否则跳过,防止误吞压缩包)。
并发:复用 `internal/service` 现有的 worker 池写法,默认 2–4 并发解析(解析要读文件,IO 密集)。
### 4.2 各格式解析策略
| 格式 | 元信息 | 章节 / 页 | 正文读取 |
| --- | --- | --- | --- |
| **EPUB** | zip → `META-INF/container.xml` → OPF → `dc:title/dc:creator/dc:language/dc:description`;封面取 OPF `meta[name=cover]` 指向项,退化到 `guide` | 按 spine 顺序,标题取每个 XHTML 的 `<title>` 或首个 `h1..h3`;`Level` 由 nav/ncx 的嵌套深度推断 | `archive/zip` 定位 `Href` 条目,读出 XHTML → 服务端清洗后返回 |
| **TXT** | 文件名(`书名 - 作者.txt` 模式)+ 编码探测 | 正则切分:`第[一二三四五六七八九十百千零两0-9]+[章节卷回篇]`、`Chapter\s+\d+`、`^\s*\d+\s*$`;命中不足 3 个则按固定字节窗口切片 | `io.SectionReader` 读 `[StartOffset, EndOffset)` → 按探测到的编码转 UTF-8 |
| **PDF** | 首页/元数据(页数、标题);封面渲染首屏,失败则留空 | 单章「正文」,`PageStart/PageEnd` = 全书页 | 原始文件流(Range),前端 pdf.js 自己解析 |
| **CBZ / CBR** | zip/rar 条目自然排序,第一张图做封面 | 单章,页区间 = 图片条目序号 | 按页解压单条目,走图片响应路径 |
| **图片文件夹** | 目录名 | 单章,页区间 = 排序后图片序号 | 直接读本地文件 |
| **MOBI / AZW3** | PalmDOC / KF8 头 | 首版**只入库展示、不支持在线阅读**,详情页给出「下载原文件」入口 | — |
实现细节提示:
- 编码探测用 `golang.org/x/text`(已是 `go.mod` 间接依赖)。GBK/Big5/UTF-16LE 都要覆盖,中文网文 TXT 大量是 GBK。
- CBR 需要 RAR 解压。建议引入纯 Go 的 `github.com/nwaples/rardecode`;若不接受新依赖,首版把 CBR 归入「只入库、不可读」。
- EPUB XHTML 清洗**必须在服务端做**:剔除 `<script>`、`on*` 事件属性、`<iframe>`、外部 `http(s)` 资源引用,把 `src/href` 重写为 `/api/books/:id/res/*`。前端再叠一层 DOMPurify 作为纵深防御。
### 4.3 网盘书籍的读取策略
网盘直链的核心约束:**EPUB / CBZ 的解析必须能读到文件尾部**(zip 中央目录在末尾),但 `cloud.Provider.Resolve` 返回的是短时效 URL,且 115 直链依赖 UA/Cookie(`DirectLink.Headers`),浏览器无法直接携带。
因此分两条路径:
**A. 解析阶段 —— 完整拉取到缓存目录**
```
<CacheDir>/books/<sourceID>/<hash>.<ext>
```
`BookParser` 通过 `DirectLink` 拉全量文件到缓存后再解析。缓存目录复用 `config.CacheConfig.CacheDir`(默认 `<DataDir>/cache`,容器里是 `/cache`),容量上限新加一项 `CacheConfig.BooksMaxSizeMB`(默认 2GB),走 LRU 淘汰。缓存命中的书后续正文读取也直接走本地,不再回网盘。
**B. 阅读阶段 —— 优先本地缓存,未命中走代理流**
未缓存时由服务端反代目标 URL(`DirectLink.Proxy=true` 时同样反代),并把 `Content-Type: image/*` 或 `application/pdf` 透传给前端。反代实现直接参照 `internal/service/cloud115_hls_proxy.go` 的响应头透传白名单(`Content-Type/Content-Length/Content-Range/Accept-Ranges/ETag/Last-Modified`)。
**C. 阅读进度与文件解耦** —— 代码里区分「源」「位置」:
```go
type BookLocator struct {
Kind string `json:"kind"` // local / cloud
LocalPath string `json:"local_path,omitempty"`
CloudConfig string `json:"cloud_config,omitempty"`
CloudRef string `json:"cloud_ref,omitempty"`
Href string `json:"href,omitempty"` // zip 内条目
StartOffset int64 `json:"start_offset,omitempty"`
EndOffset int64 `json:"end_offset,omitempty"`
}
```
被缓存的书 `Kind` 仍报 `cloud`(进度不绑物理位置),这样缓存被淘汰后进度依然有效。这是不把 `Book.Path` 直接存成本地缓存路径的原因。
### 4.4 磁盘与容器
书籍目录需要在 compose 里挂载,并在 README 的部署档位表补充说明。新缓存目录复用现有 `MEBOX_CACHE_CACHE_DIR`(`docker-compose.simple.yml` 中为 `/cache`),无需新增环境变量。
---
## 5. HTTP API 设计
全部挂在 `/api/books/*`,注册在 `registerAuthedBookRoutes`。响应统一走 `internal/handler/response.go` 的既有助手。
### 5.1 书库与扫描(管理端)
| 方法 | 路径 | 权限 | 说明 |
| --- | --- | --- | --- |
| GET | `/api/books/libraries` | `can_read_books` | 列表,按 `AllowedBookLibraryIDs` 过滤可见性 |
| POST | `/api/books/libraries` | `can_manage_book_library` | 新建/更新书库 |
| DELETE | `/api/books/libraries/:id` | `can_manage_book_library` | 删除(含级联软删 books) |
| GET | `/api/books/libraries/:id/sources` | `can_manage_book_library` | 书源列表 |
| POST | `/api/books/libraries/:id/sources` | `can_manage_book_library` | 新增书源(本地目录 / 网盘路径) |
| POST | `/api/books/libraries/:id/scan` | `can_manage_book_library` | 触发扫描,返回 task id |
| GET | `/api/books/scan/status` | `can_manage_book_library` | 扫描进度轮询 |
| GET | `/api/books/browse` | `can_manage_book_library` | 网盘路径浏览(复用 cloud Provider.List) |
### 5.2 书架与详情
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/api/books` | 书架列表。参数:`library_id`、`keyword`、`media_kind`、`format`、`tag`、`sort`(`title/added_at/last_read/progress`)、`page/page_size` |
| GET | `/api/books/continue-reading` | 最近在读,首页「继续阅读」区块用 |
| GET | `/api/books/:id` | 详情(元信息 + 目录树 + 当前用户进度 + 收藏态) |
| GET | `/api/books/:id/cover` | 封面。走 `ImageProxy` 的缓存与缩放,参数 `w` |
| GET | `/api/books/:id/chapters/:index` | 章节正文。电子书返回 `text/html`(已清洗)或 `application/json` 结构化段落 |
| GET | `/api/books/:id/res/*path` | EPUB 内部资源(图片/字体/CSS),路径参数为 zip 内条目 |
| GET | `/api/books/:id/pages/:index` | 漫画单页图片,`Content-Type: image/*` + 长效缓存头 |
| GET | `/api/books/:id/file` | 原始文件流(Range),pdf.js 与「下载原文件」共用 |
| POST | `/api/books/:id/favorite` | 收藏 / 取消收藏 |
| DELETE | `/api/books/:id` | 删除(`can_manage_books`) |
**章节响应格式(推荐 JSON 而非裸 HTML)**:
```json
{
"index": 12,
"title": "第十二章 雨夜",
"char_count": 3820,
"blocks": [
{ "type": "p", "text": "……" },
{ "type": "img", "src": "/api/books/xxx/res/images/1.png" }
],
"next_index": 13,
"prev_index": 11
}
```
用结构化 blocks 而非 HTML 的理由:
1. 前端可安全渲染,不必 `dangerouslySetInnerHTML`,彻底绕开 XSS 面。
2. 分页模式需要按节点测量高度做分栏,结构化的段落数组比操作 DOM 简单得多。
3. 字号/行距/主题切换只需重渲染,不碰 HTML 字符串。
保底方案:`?format=html` 仍返回清洗后的 HTML,供 EPUB 中复杂排版(表格、脚注、双向文字)回退。
### 5.3 进度、批注、统计
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/api/books/:id/progress` | 当前用户进度 |
| PUT | `/api/books/:id/progress` | 保存进度。前端**节流 5s + 页面卸载时 `sendBeacon`** |
| GET | `/api/books/:id/annotations` | 批注列表 |
| POST | `/api/books/:id/annotations` | 新建 |
| PATCH | `/api/books/:id/annotations/:aid` | 修改 note / color |
| DELETE | `/api/books/:id/annotations/:aid` | 删除 |
| POST | `/api/books/:id/heartbeat` | 阅读心跳,body 为 `{seconds, chars, pages}`,落 `book_reading_sessions` 小时桶 |
| GET | `/api/books/reader-settings` | 读取当前用户的阅读器偏好(`model.User.ReaderSettings`) |
| PUT | `/api/books/reader-settings` | 保存阅读器偏好(服务端只做长度与枚举校验后原样存储) |
| GET | `/api/books/stats` | 个人统计:累计时长、已读书数、在读、近 30 天热力图 |
| GET | `/api/books/stats/overview` | 管理员视角:全站阅读统计(`can_access_settings`) |
进度写入的并发安全:`uniq_user_book` 复合唯一键 + 先 `Updates` 再 `Create` 的 upsert 模式。**参照 `internal/database/schema_migration.go` 里 `dedupePlaybackHistories` 的前车之鉴**——`PlaybackHistory` 曾因 read-then-write 产生重复行导致唯一索引进不去,新表直接写 upsert,不要复制那个 bug。
---
## 6. 前端设计
### 6.1 路由与导航
`web/src/appRoutes.tsx` 新增懒加载路由:
```tsx
const BookshelfPage = lazy(() => import('./pages/BookshelfPage').then(m => ({ default: m.BookshelfPage })))
const BookDetailPage = lazy(() => import('./pages/BookDetailPage').then(m => ({ default: m.BookDetailPage })))
const BookReaderPage = lazy(() => import('./pages/BookReaderPage').then(m => ({ default: m.BookReaderPage })))
const BookStatsPage = lazy(() => import('./pages/BookStatsPage').then(m => ({ default: m.BookStatsPage })))
const BookLibraryPage = lazy(() => import('./pages/BookLibraryPage').then(m => ({ default: m.BookLibraryPage })))
```
```
/books 书架
/books/:id 书籍详情(目录、元信息、开始阅读)
/books/:id/read 阅读器(全屏,隐藏底栏)
/books/stats 阅读统计
/books/library 书库管理(adminOnly)
```
`web/src/components/layoutNavigation.ts` 的改动:
- `MEDIA_NAV_ITEMS` 与 `MOBILE_BOTTOM_NAV_ITEMS` 加「阅读」项(`BookOpen` 图标),普通用户可见。
- 新增 `isBookReaderRoute(pathname)`,并在 `shouldShowMobileBottomNav` 中排除 `/books/:id/read`,与 `isPlayerRoute` 的处理一致。
- `resolveHeaderBack` 补 `/books/...` 的返回链。
- `LAYOUT_NAV_ITEMS` 加「书库管理」条目,`adminOnly: true`。
### 6.2 页面组成
```
web/src/pages/
BookshelfPage.tsx 书架:筛选栏 + 网格/列表双视图 + 继续阅读横滑
BookDetailPage.tsx 详情:封面、元信息、目录树、进度、开始/继续阅读
BookReaderPage.tsx 阅读器外壳:顶栏 + 内容区 + 底部工具条 + 设置抽屉
BookStatsPage.tsx 统计:热力图 + 概览卡片
BookLibraryPage.tsx 书库管理:书库 CRUD + 书源 CRUD + 扫描触发与进度
web/src/components/
book/ReaderCore.tsx 渲染内核分发(按 media_kind + format)
book/ScrollReader.tsx 滚动流式
book/PagedReader.tsx 分页翻页
book/ComicReader.tsx 漫画(单页/双页/右开本/预加载)
book/PdfReader.tsx PDF(pdf.js)
book/ReaderToolbar.tsx 顶栏 + 底栏(章节、进度、目录、批注、设置)
book/ReaderSettingsPanel.tsx 阅读设置
book/ChapterTocDrawer.tsx 目录抽屉
book/AnnotationList.tsx 书签笔记列表
book/ReaderProgressBar.tsx 进度条(可拖拽跳章)
web/src/api/books.ts 接口封装
```
对于 4 类内容,`ReaderCore` 的分发是第一层决策:
| `media_kind` | `format` | 内核 |
| --- | --- | --- |
| ebook | epub / txt | `ScrollReader` 或 `PagedReader`(按 `settings.mode`) |
| ebook | pdf | `PdfReader` |
| comic | cbz / cbr / folder | `ComicReader` |
| ebook | mobi / azw3 | 不提供阅读,仅详情页 |
### 6.3 渲染内核选型(关键决策)
**结论:自研内核,不引入 epub.js / foliate-js。**
对比:
| 方案 | 优点 | 缺点 |
| --- | --- | --- |
| `epub.js` | 成熟、CFI 精确定位、多列分页开箱即用 | 维护停滞;CFI 定位难以与自研进度模型(`charOffset` / `percent`)对齐;PDF/漫画仍需另做两个内核 |
| `foliate-js` | 一套 API 覆盖 EPUB/MOBI/CBZ/PDF,排版质量高 | 生态小、文档薄、非稳定 API,需要 vendored 一份并自行承担升级风险 |
| **自研(推荐)** | 进度模型完全可控、跨端一致;零新增重依赖;与既有 Tailwind 主题体系天然统一 | 需要自己实现分页测量、脏 HTML 清洗、资源重写 |
自研方案的可行性依据:分页的本质是「CSS multi-column 布局 + `transform: translateX` 平移」,foliate-js 也是这么做的,核心约 200 行;滚动模式的虚拟化可以直接复用已有的 `react-virtuoso`(已在 `web/package.json`,用于 `VirtualMediaGrid`)。
自研必须做好的三件事:
1. **HTML 清洗**:服务端为主(见 4.2),前端用 `dompurify` 兜底。这是新增的唯一运行时依赖。
2. **资源重写**:EPUB 内部图片/字体/CSS 的 `src`、`href`、`url()` 全部重写到 `/api/books/:id/res/`,否则相对路径会 404。
3. **分页测量与重排**:容器尺寸变化(窗口 resize、字号切换、横竖屏)后必须重新分页,并把「当前段落 + 段内比例」作为锚点恢复位置,不能让用户跳回章首。
### 6.4 双模式实现
**滚动模式(`ScrollReader`)**
- 章内虚拟化:单章文本通常 2k–10k 字,直接整章渲染即可;跨章用「当前章 + 前后各一章」的窗口,滚动到边界时无缝追加。
- 进度:`IntersectionObserver` 观测可视段落,映射为 `charOffset`;`scroll_ratio` 同时上报。
- 优势:移动端体验好,实现简单,长段落无分页误差。
**分页模式(`PagedReader`)**
- 章内:容器设为多列(`column-width: <contentWidth>`),`overflow: hidden`,通过 `translateX` 翻页;总页数由 `scrollWidth / containerWidth` 得出。
- 跨章:翻到本章末尾自动加载下一章首页;反向同理。章首/章尾需处理「残页合并」,避免出现半屏空白页。
- 输入:左右方向键、空格、点击左右热区、滑动手势(移动端)。`comicDirection`/`pageAnimation` 控制方向与动画。
- 进度:`chapter_index` + `page_index` 映射回 `charOffset`。
两种模式共享 `BookProgress`,切换模式时用「章 + 比率」换算,不丢位置。
### 6.5 状态与持久化
- 阅读器设置来自 `authStore` 的用户信息(`ReaderSettings` 反序列化),改动后 `PUT /api/books/reader-settings` 持久化 + 本地 `localStorage` 兜底(首屏渲染不等接口)。
- 进度本地先写 `localStorage`(key `mebook:book:<id>:pos`),再节流同步服务端;页面隐藏/卸载用 `navigator.sendBeacon` 保证不丢。
- 新增 `web/src/stores/readerSettings.ts`(zustand),与既有 `playProfile.ts` 组织方式一致。
---
## 7. 权限与多用户
`model.UserPermission` 新增 4 位(当前 18 个字段,加后 22 位):
| 权限位 | 默认 | 含义 |
| --- | --- | --- |
| `can_read_books` | `true` | 书架、阅读、进度、批注 |
| `can_manage_book_library` | `false` | 书库 / 书源 CRUD、触发扫描、网盘浏览 |
| `can_manage_books` | `false` | 编辑书籍元信息、删除书、手动重新解析 |
| `can_view_book_stats` | `false` | 查看全站阅读统计 |
同步改动清单(**漏一处就会出现「后端有权限、前端不显示开关」的静默 bug**):
1. `internal/model/permission.go` — 字段、`NewDefaultPermission()`、`PermissionMap()`,并更新文件头注释里的数量描述(注释目前写「19项」,实际 18 个字段,顺手修正)。
2. `web/src/types/auth.ts` — `PermissionFlags` 接口加 4 个字段。
3. `web/src/stores/permissions.ts` — 默认值对象、中文标签映射、权限分组数组。
4. `web/src/hooks/usePermission.ts` — 若其中有分组注释需同步。
5. `internal/handler/permissions.go` — 权限矩阵响应(若有枚举)。
6. `web/src/pages/AdminUsersForm.tsx` / 权限勾选 UI — 若按分组硬编码了列表。
书库可见性:
- 管理员在用户管理页勾选该用户可访问的书库,写入 `User.AllowedBookLibraryIDs`。
- 空值 = 不限制(与影视库语义一致)。
- 过滤集中在一个 `bookVisibility` 助手,与现有的 `internal/handler/visibility.go` 并列(该文件就是影视库可见性的集中判定点,并且会与 `PlayProfile.AllowedLibraryIDs` 求交集)。**阅读模块首版不接播放配置档**——`PlayProfile` 是影视播放器概念(音量、转码参数、PIN),与阅读无关;但判定入口要与它放在同一层,将来若要按配置档限制书库才不用重构。
- **服务端强制**:`GET /api/books/:id`、章节、页面、资源(`/res/*`)、封面、原始文件流,**所有**按 ID 取内容的接口都要校验 `book.LibraryID ∈ 用户可见书库`,不能只靠书架列表过滤。这是最容易漏的越权点:`/api/books/:id/res/*path` 会直接吐出书籍内部的原始资源,漏校验等于开放全库文件读取。
- 用户被取消书库授权后,其 `BookProgress` / `BookAnnotation` 保留不删(授权恢复即恢复),但接口一律按当前可见性判定,不因历史数据放行。
---
## 8. 阅读统计
- **采集**:阅读器每 30s 发一次 `heartbeat`,卸载时补发一次;服务端按 `(user_id, book_id, 小时桶)` 累加,行数上限 = 用户数 × 书数 × 阅读小时数,可控。
- **汇总**:`Scheduler` 每日 03:00 把昨天之前的 session 滚进 `BookDailyStat`(复用 `service.Scheduler` 的既有定时任务注册方式)。
- **展示**:
- 个人页「阅读统计」:累计时长、读完本数、在读本数、近 30 天热力图(仿 GitHub 贡献图)、阅读类型分布(电子书 / 漫画)。
- 首页新增「继续阅读」横滑区块(参照 `HomePageSections.tsx` 里既有区块的写法)。
- 管理员视图:全站活跃度、热门书籍 Top 20(需 `can_view_book_stats`)。
隐私:统计仅对本人与管理员可见;管理员视图只出聚合数据,不暴露单个用户的阅读内容。
---
## 9. 分期实施计划
### P0 — 端到端可用(本地书库 / EPUB + TXT 电子书)
目标:能扫库、能在网页上把一本书读完、关掉浏览器再打开能续读。
| # | 交付物 |
| --- | --- |
| 1 | `internal/model/book.go` 九张表 + `AllModels()` 注册 + 迁移验证(SQLite 与 PostgreSQL 各跑一次升级) |
| 2 | `BookLibrary` / `BookSource` / `Book` / `BookChapter` / `BookProgress` 的 repository |
| 3 | `BookParser`:EPUB 与 TXT 解析(含 GBK 编码探测、章节正则切分、封面提取) |
| 4 | `BookScannerService`:本地目录扫描 + upsert + 进度广播 |
| 5 | API:书库 CRUD、书源 CRUD、扫描、书架列表、详情、章节正文、封面、进度读写、阅读器偏好读写 |
| 6 | 前端:`BookshelfPage`、`BookDetailPage`、`BookReaderPage`(仅滚动模式)、目录抽屉、阅读设置面板 |
| 7 | 权限:4 个权限位 + `AllowedBookLibraryIDs` 全链路(含服务端越权校验) |
**验收标准**
- 一个含 50 本 EPUB 与 20 本 GBK 编码 TXT 的目录,扫描后书架正确列出,标题/作者/封面/章节目录无误。
- 任意一本书可连续阅读 3 章以上,刷新页面后回到原位置(误差 < 1 段)。
- 权限为 `can_read_books=false` 的账号访问 `/api/books` 返回 403;直接请求他人书库的 `/api/books/:id/chapters/0`、`/api/books/:id/res/*`、`/api/books/:id/file` 同样被拒。
- SQLite 单文件档与 PostgreSQL 档都能从旧版本升级启动,无迁移报错。
### P1 — 漫画 + 分页模式 + 网盘直链
| # | 交付物 |
| --- | --- |
| 1 | `ComicReader`:CBZ 解析、单页/双页、右开本、相邻页预加载 |
| 2 | `PagedReader`:分页测量、resize 重排、跨章衔接、键鼠与手势输入 |
| 3 | 网盘书源:`StorageKind=cloud` 的书源配置、`cloud.Provider` 接入、本地缓存目录 + LRU 淘汰 |
| 4 | 网盘书籍的索引拉取与阅读反代(含 `Content-Range` 透传) |
| 5 | PDF:`PdfReader`(pdf.js)+ Range 文件流接口 |
| 6 | 图片文件夹型漫画 |
**验收标准**
- CBZ 单册 300 页可流畅翻阅,双页模式断页处理正确(避免跨章错配)。
- 分页模式下切换字号、resize 窗口、手机横竖屏切换后,位置不跳、不出现空白页。
- 挂在 OpenList 与 115 上的 EPUB 能正常入库并在线阅读,缓存目录达到上限后按 LRU 淘汰且不影响已有进度。
- 20MB 以上 PDF 可跳页、可缩放。
### P2 — 批注、统计与体验打磨
| # | 交付物 |
| --- | --- |
| 1 | 划线 / 书签 / 笔记:`BookAnnotation` 接口与 UI,批注列表与跳转 |
| 2 | 阅读统计:心跳采集、每日汇总任务、个人统计页、首页「继续阅读」区块 |
| 3 | 管理员统计视图 + 热门书籍排行 |
| 4 | 书库定时扫描(`Scheduler` 接入,默认关闭) |
| 5 | 书架高级筛选与排序、合集(系列)聚合视图 |
| 6 | MOBI/AZW3 元信息解析(仍不做在线阅读,仅提供下载) |
| 7 | 部署文档与 compose 注释更新(书籍目录挂载说明) |
### P3 — 可选增强
听书 TTS、跨设备批注冲突合并、书源自动整理(仿 `OrganizerService`)、EPUB 阅读器内注释锚点高亮。
---
## 10. 风险与待拍板项
### 10.1 需要你拍板的两点
**① 网盘书籍的缓存策略**
- 选项 A(本方案):索引时完整下载到缓存目录,阅读时优先本地。省流量、体验好,但全新书首次打开有等待,且占用磁盘(默认 2GB 上限)。
- 选项 B:完全不落盘,每次按 Range/整文件从网盘拉。省磁盘,但每次打开都要重新下载,网盘限速时体验很差。
- 选项 C:折中——只对 EPUB/CBZ 缓存(解析必须读全文),漫画原图与 PDF 走流式。
我的建议是 **C**,因为它把「必须落盘」和「可以不落盘」分开了。
**② 章节正文的返回格式**
- JSON blocks(本方案推荐):安全、便于分页测量,但复杂 EPUB 排版(表格、脚注、竖排)会降级。
- 清洗后 HTML:保真度高,但前端要 `dangerouslySetInnerHTML`,XSS 面更大。
- 我的建议是 **JSON blocks 为主 + `?format=html` 回退**,两者都实现,前端在遇到 `type: "html-block"` 时回退渲染。
### 10.2 技术风险
| 风险 | 影响 | 缓解 |
| --- | --- | --- |
| 自研分页内核的边界情况多(残页、跨章、RTL、竖排) | P1 可能超期 | P0 先只做滚动模式;分页单独立项,配套 `playerPageModel.test.ts` 那样的单测 |
| TXT 章节正则对网文变体覆盖不足 | 目录错乱 | 提供「手动重新切分」入口,规则可配(仿 `RecognitionWordsPanel` 的可配置词表模式) |
| 网盘直链失效 / 限速 / 防盗链 | 阅读中断 | 复用现有 115 换链与 `url_cache.go` 的缓存机制;失败时前端降级为「下载原文件」 |
| 大 TXT(>50MB)章节表行数过多 | SQLite 写入慢 | 章节超过阈值(如 5000 章)时按固定窗口粗切,或改为「按需切分 + 缓存到章节表」的惰性策略 |
| 缓存目录膨胀 | 磁盘打满 | 容量上限 + 复用 `service.PruneImageCache` 的 LRU 清理 + 系统设置页可见 |
| 数据库迁移对老库不兼容 | 升级失败 | 新表全部是纯新增,无列变更;不触碰 `ensurePostgresColumnCompatibility` 的既有语句 |
### 10.3 不引入的新依赖清单
| 依赖 | 用途 | 取舍 |
| --- | --- | --- |
| `dompurify` | 前端 HTML 清洗兜底 | **建议引入**(前端必需) |
| `pdfjs-dist` | PDF 渲染 | **建议引入**(P1) |
| `github.com/nwaples/rardecode` | CBR 解压 | 可选;不接受则 CBR 首版只入库 |
| `epub.js` / `foliate-js` | EPUB 渲染 | **不引入**,见 6.3 |
---
## 11. 测试策略
与项目现有测试密度对齐(`internal/service` 下大量 `_test.go`,前端有 `*.test.ts`):
**后端**
- `book_parser_test.go`:EPUB / TXT 各准备 fixture(`testdata/` 下小体积样本),断言元信息、章节数、章节边界字节偏移、GBK 转码正确性。
- `book_scanner_test.go`:临时目录扫描 + 重复扫描幂等 + 源文件删除后软删。
- `book_progress_test.go`:并发 upsert 不产生重复行(直接复现 `dedupePlaybackHistories` 防的那类 bug)。
- `book_permission_test.go`:越权矩阵,逐接口断言非可见书库返回 403/404。
- Handler 层:仿 `internal/handler/media_test.go` 起的 `httptest` + 真实内存 SQLite。
**前端**
- `readerModel.test.ts`:模式切换时的位置换算(`scroll ↔ paged`、`charOffset ↔ pageIndex`)、百分比计算、跨章边界。
- 分页计算的纯函数抽出单测(不含 DOM),参照 `web/src/pages/playerPageModel.test.ts` 的做法——把逻辑从组件里拔出来测,是项目已有的好传统。
---
## 12. 附:改动文件清单
**后端新增**
```
internal/model/book.go
internal/repository/book_repository.go
internal/service/book_parser.go EPUB / TXT / CBZ 解析
internal/service/book_parser_epub.go
internal/service/book_parser_txt.go
internal/service/book_parser_comic.go
internal/service/book_scanner.go
internal/service/book_reader.go 章节 / 页面 / 资源的读取与清洗
internal/service/book_progress.go
internal/service/book_stats.go
internal/service/book_cache.go 网盘缓存与 LRU
internal/handler/books.go
internal/handler/books_library.go
internal/handler/books_reader.go
internal/handler/routes_books.go
```
**后端修改**
```
internal/model/model.go AllModels() 追加 9 张表
internal/model/permission.go 4 个权限位
internal/model/user.go ReaderSettings / AllowedBookLibraryIDs
internal/repository/repository.go Container 加字段
internal/service/service.go Container 加字段 + Boot() 启动扫描
internal/handler/routes_authenticated.go 挂载 registerAuthedBookRoutes
internal/service/scheduler_local_jobs.go 书籍缓存清理 + 每日阅读统计汇总
internal/config/types.go CacheConfig 加 BooksMaxSizeMB;新增 BookConfig(扫描并发等)
docker-compose*.yml 书籍目录挂载注释
README.md / README_EN.md 能力表新增「阅读」
```
**前端新增**
```
web/src/api/books.ts
web/src/stores/readerSettings.ts
web/src/pages/BookshelfPage.tsx
web/src/pages/BookDetailPage.tsx
web/src/pages/BookReaderPage.tsx
web/src/pages/BookStatsPage.tsx
web/src/pages/BookLibraryPage.tsx
web/src/components/book/*.tsx
```
**前端修改**
```
web/src/appRoutes.tsx 4 条路由
web/src/components/layoutNavigation.ts 导航项、阅读器路由判定、返回链
web/src/types/auth.ts 权限位
web/src/stores/permissions.ts 权限位默认值 / 标签 / 分组
web/src/pages/HomePageSections.tsx 「继续阅读」区块
web/src/pages/settingsGroupBooks.ts (新增)阅读设置分组
web/src/pages/settingsGroups.ts 把 settingsGroupBooks 加入 GROUPS 数组
```
+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,在低配主机
+4 -2
View File
@@ -65,8 +65,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)
+33
View File
@@ -174,6 +174,39 @@ 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)
}
}
func TestUpdateDanmakuSettingsRejectsInvalidPlaybackRate(t *testing.T) {
svc := newDanmakuSettingsService(t)
+27
View File
@@ -40,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) != "" {
@@ -199,8 +218,15 @@ 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
}
params := service.ItemsParams{
UserID: embyEffectiveUserID(c),
@@ -208,6 +234,7 @@ func embyShowEpisodesHandler(svc *service.Container) gin.HandlerFunc {
IncludeItemTypes: []string{"Episode"},
Recursive: true,
Limit: 500,
SeasonIndex: seasonIndex,
}
out, err := svc.Emby.Items(c.Request.Context(), params)
if err != nil {
@@ -0,0 +1,211 @@
package handler
import (
"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/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()
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 _, m := range []model.Media{
{
Base: model.Base{ID: "s1e1"},
LibraryID: lib.ID,
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"},
LibraryID: lib.ID,
Title: "Test Show",
Path: "D:\\media\\tv\\Test Show\\Season 02\\Test Show - S02E01.mkv",
SeasonNum: 2,
EpisodeNum: 1,
Container: "mkv",
},
} {
if err := db.Create(&m).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 fetchEpisodeIDs(t *testing.T, router *gin.Engine, secret, path string) ([]string, 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)
}
ids := make([]string, 0, len(payload.Items))
for _, item := range payload.Items {
id, _ := item["Id"].(string)
ids = append(ids, id)
}
return ids, int(payload.TotalRecordCount)
}
// 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)
seasonsPath := "/Shows/" + seriesID + "/Seasons"
req := httptest.NewRequest(http.MethodGet, seasonsPath, 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)
}
var season1ID string
for _, s := range seasons.Items {
if index, ok := s["IndexNumber"].(float64); ok && int(index) == 1 {
season1ID, _ = s["Id"].(string)
}
}
if season1ID == "" {
t.Fatalf("season 1 not found in %#v", seasons.Items)
}
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)
}
}
+12 -9
View File
@@ -37,15 +37,18 @@ type User struct {
// 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"`
PlayerPlaybackRate float64 `gorm:"not null;default:1" json:"player_playback_rate"`
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"`
PlayerPlaybackRate float64 `gorm:"not null;default:1" json:"player_playback_rate"`
// 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
+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,
+8
View File
@@ -71,6 +71,8 @@ type DanmakuRenderConfig struct {
Volume float64 `json:"volume"`
// PlaybackRate 是当前用户的播放倍速偏好(按用户存储)。
PlaybackRate float64 `json:"playback_rate"`
// Vr360GuideSeen 表示当前用户是否已经看过 VR 全景播放的首次操作说明。
Vr360GuideSeen bool `json:"vr360_guide_seen"`
// MergeSources 是当前用户的弹幕合并偏好(按用户存储)。
MergeSources bool `json:"merge_sources"`
// AppKeyConfigured 只表明用户是否保存了应用密钥,不回传密钥明文。
@@ -98,6 +100,8 @@ type DanmakuSettingsPatch struct {
MergeSources *bool `json:"merge_sources"`
Volume *float64 `json:"volume"`
PlaybackRate *float64 `json:"playback_rate"`
// Vr360GuideSeen 标记 VR 全景播放的首次操作说明是否已经看过。
Vr360GuideSeen *bool `json:"vr360_guide_seen"`
}
type danmakuUserContextKey struct{}
@@ -447,6 +451,7 @@ func danmakuConfigFromUser(user *model.User) DanmakuRenderConfig {
Area: strconv.FormatFloat(area, 'f', -1, 64),
Volume: volume,
PlaybackRate: playbackRate,
Vr360GuideSeen: user.PlayerVr360GuideSeen,
MergeSources: user.DanmakuMergeSources,
AppKeyConfigured: strings.TrimSpace(user.DanmakuAppKey) != "",
}
@@ -528,6 +533,9 @@ func (s *DanmakuService) UpdateUserSettings(ctx context.Context, userID string,
}
updates["player_playback_rate"] = *patch.PlaybackRate
}
if patch.Vr360GuideSeen != nil {
updates["player_vr360_guide_seen"] = *patch.Vr360GuideSeen
}
if len(updates) == 0 {
return DanmakuRenderConfig{}, ErrNoDanmakuSettings
}
+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
+4
View File
@@ -141,6 +141,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 (
+10
View File
@@ -47,10 +47,20 @@ 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)}, "|")))
+21 -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
+157 -1
View File
@@ -79,6 +79,13 @@ 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
}
type embyRemotePersonImageRef struct {
@@ -86,6 +93,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{
@@ -819,6 +829,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 +844,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())
@@ -1153,6 +1165,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)
@@ -1160,7 +1316,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 的映射,供旧式
@@ -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)
}
}
+14 -9
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
@@ -914,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 ""
}
@@ -924,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)。
+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 }
+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}
+34
View File
@@ -27,8 +27,19 @@ type FFprobeService struct {
log *zap.Logger
mu sync.RWMutex
limiter chan struct{}
// availMu guards the short-lived availability cache used by Available().
availMu sync.Mutex
availCheckedAt time.Time
availValue bool
}
// ffprobeAvailabilityTTL bounds how long an Available() result is reused.
// Resolving a binary stats up to a couple dozen candidate paths, and the scanner
// consults availability per root while deciding whether to queue backfill
// probes. A short TTL keeps a freshly installed ffmpeg visible within seconds.
const ffprobeAvailabilityTTL = 30 * time.Second
// NewFFprobeService is the constructor.
func NewFFprobeService(cfg *config.Config, log *zap.Logger) *FFprobeService {
maxConcurrent := normalizeFFprobeMaxConcurrent(cfg.App.FFprobeMaxConcurrent)
@@ -54,6 +65,29 @@ func (f *FFprobeService) SetMaxConcurrent(n int) {
f.limiter = make(chan struct{}, normalizeFFprobeMaxConcurrent(n))
}
// Available reports whether a probe can actually run right now: either an
// ffprobe binary or the ffmpeg fallback must be resolvable. The scan path uses
// this to decide whether re-queueing probes for media that still lack technical
// metadata is worth doing — without a binary every probe would just fail.
func (f *FFprobeService) Available() bool {
if f == nil || f.cfg == nil {
return false
}
f.availMu.Lock()
defer f.availMu.Unlock()
if !f.availCheckedAt.IsZero() && time.Since(f.availCheckedAt) < ffprobeAvailabilityTTL {
return f.availValue
}
available := true
if _, err := resolveLocalExecutable(f.cfg.App.FFprobePath, "ffprobe"); err != nil {
_, ffmpegErr := resolveLocalExecutable(f.cfg.App.FFmpegPath, "ffmpeg")
available = ffmpegErr == nil
}
f.availValue = available
f.availCheckedAt = time.Now()
return available
}
// ProbeResult is the subset of ffprobe output consumed by the scanner.
type ProbeResult struct {
DurationSec int
+25 -2
View File
@@ -38,6 +38,12 @@ type ImageProxy struct {
mu sync.Mutex
fetchGroup singleflight.Group
// directClient bypasses HTTP_PROXY / OS proxy settings. It is built once and
// shared: rebuilding the transport on every fetch discarded all keep-alive
// connections, so every burst of poster requests paid a fresh TCP (and TLS)
// handshake per image.
directClient *http.Client
// resizeSem bounds concurrent decode/resize jobs. Emby TV clients request
// poster grids in bursts; letting every request decode a source image at
// once causes CPU and memory spikes that make the whole UI feel sluggish.
@@ -65,6 +71,12 @@ const (
imageBrowserCacheControl = "public, max-age=2592000, immutable"
imagePlaceholderCacheControl = "no-store"
imageMaxResizeConcurrency = 4
// imageOriginalCacheSubdir 存放上游原图(生成各种尺寸的原料,可再生)。
imageOriginalCacheSubdir = "originals"
// imageRenditionCacheSubdir 存放挂载 Emby 直接按尺寸产出的成品图:它们
// 已经是客户端要的最终尺寸,和派生缩略图一样属于长期保留的热路径缓存。
imageRenditionCacheSubdir = "renditions"
)
// NewImageProxy is the constructor.
@@ -113,6 +125,7 @@ func NewImageProxy(cfg *config.Config, log *zap.Logger) *ImageProxy {
}
proxy.client = &http.Client{Timeout: 30 * time.Second, Transport: transport}
proxy.directClient = &http.Client{Timeout: 30 * time.Second, Transport: NewInternalTransport()}
return proxy
}
@@ -207,10 +220,20 @@ func imageResizeConcurrency() int {
}
// Prune removes oldest cached images until disk usage is within the configured limit.
// 原图池按独立配额与保留时长优先淘汰,客户端热路径读取的成品图最后淘汰。
func (p *ImageProxy) Prune() (PruneImageCacheResult, error) {
if p.cfg == nil || p.cfg.Cache.ImagesMaxSizeMB <= 0 {
return PruneImageCacheResult{}, nil
}
maxBytes := int64(p.cfg.Cache.ImagesMaxSizeMB) * 1024 * 1024
return PruneImageCache(p.cacheDir, maxBytes)
var originalsMaxBytes int64
if p.cfg.Cache.ImagesOriginalsMaxSizeMB > 0 {
originalsMaxBytes = int64(p.cfg.Cache.ImagesOriginalsMaxSizeMB) * 1024 * 1024
}
var originalsMaxAge time.Duration
if p.cfg.Cache.ImagesOriginalsTTLHours > 0 {
originalsMaxAge = time.Duration(p.cfg.Cache.ImagesOriginalsTTLHours) * time.Hour
}
totalBytes := int64(p.cfg.Cache.ImagesMaxSizeMB) * 1024 * 1024
pools := ImageCachePools(p.cacheDir, originalsMaxBytes, originalsMaxAge)
return PruneImageCachePools(pools, totalBytes)
}
+112 -20
View File
@@ -4,6 +4,8 @@ import (
"bytes"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"os"
@@ -68,17 +70,7 @@ func serveCachedPlaceholder(w http.ResponseWriter) {
_, _ = w.Write(transparent1x1PNG)
}
func (p *ImageProxy) cloudImageCachePaths(stableKey string) (string, string, string) {
stableKey = strings.TrimSpace(stableKey)
if stableKey == "" {
stableKey = "unknown"
}
sum := sha256.Sum256([]byte("cloud-image:" + stableKey))
key := "cloud-" + hex.EncodeToString(sum[:])
cachePath := filepath.Join(p.cacheDir, key)
return key, cachePath, cachePath + ".fail"
}
// remoteImageCachePaths 返回上游原图池的路径(带 URL 校验)。
func (p *ImageProxy) remoteImageCachePaths(raw string) (string, string, string, error) {
if _, err := p.validateURL(raw); err != nil {
return "", "", "", err
@@ -87,13 +79,120 @@ func (p *ImageProxy) remoteImageCachePaths(raw string) (string, string, string,
return key, cachePath, failPath, nil
}
// remoteImageCachePathsForValidated 返回上游原图池的缓存路径。
func (p *ImageProxy) remoteImageCachePathsForValidated(raw string) (string, string, string) {
sum := sha256.Sum256([]byte(raw))
return p.remoteImageCachePathsInPool(raw, imageOriginalCacheSubdir)
}
// remoteImageCachePathsInPool 生成某个池内的缓存路径。池决定清理策略:
// 原图池小配额 + 短保留,成品池(挂载 Emby 按尺寸产出的图)长期保留。
func (p *ImageProxy) remoteImageCachePathsInPool(raw, pool string) (string, string, string) {
sum := sha256.Sum256([]byte(imageCacheKeyURL(raw)))
key := hex.EncodeToString(sum[:])
cachePath := filepath.Join(p.cacheDir, key)
cachePath := filepath.Join(p.cacheDir, pool, key)
return key, cachePath, cachePath + ".fail"
}
// remoteImageCachePathsEveryPool 返回同一个地址在所有池中的缓存路径。调用方
// 只知道原始 URL,无法判断它是原图还是挂载 Emby 按尺寸返回的成品,因此清理
// 类操作(refresh/retry)需要两个池都试一遍。
func (p *ImageProxy) remoteImageCachePathsEveryPool(raw string) [][3]string {
pools := []string{imageOriginalCacheSubdir, imageRenditionCacheSubdir}
out := make([][3]string, 0, len(pools))
for _, pool := range pools {
key, cachePath, failPath := p.remoteImageCachePathsInPool(raw, pool)
out = append(out, [3]string{key, cachePath, failPath})
}
return out
}
// imageProxyMaxDownloadBytes 是单张图片的下载上限,防上游异常返回超大响应
// 把磁盘和内存打满。
const imageProxyMaxDownloadBytes = 32 << 20
var (
// errImageCacheUnavailable 表示缓存目录不可写、连临时文件都建不出来。
// 它发生在读取响应体之前,因此调用方还能退回内存缓冲,保证图片不因
// 运维异常(磁盘只读/满)全部变成占位图。
errImageCacheUnavailable = errors.New("image cache is not writable")
// errImageCacheCommitFailed 表示响应体已被消费但无法提交到缓存(写入
// 中断、rename 失败等),此时无法重放响应体,只能按拉取失败处理。
errImageCacheCommitFailed = errors.New("image cache commit failed")
)
// cachedImageFileValid 只读取文件头判断缓存文件是否是可用图片。透明占位图
// 与空文件都视为不可用(历史实现曾把上游失败时的占位图写进缓存)。
func cachedImageFileValid(path string) error {
file, err := os.Open(path) // #nosec G304 -- cache paths are SHA-derived under cacheDir.
if err != nil {
return err
}
defer file.Close()
stat, err := file.Stat()
if err != nil || stat.IsDir() || stat.Size() <= 0 {
return errImageProxyNonImageContent
}
headerSize := 512
if stat.Size() < int64(headerSize) {
headerSize = int(stat.Size())
}
header := make([]byte, headerSize)
n, readErr := io.ReadFull(file, header)
if readErr != nil && !errors.Is(readErr, io.ErrUnexpectedEOF) {
return readErr
}
header = header[:n]
if !isImageContentType(detectContentType(header)) {
return errImageProxyNonImageContent
}
// 透明占位图恰好 67 字节;只看头部就够(detectContentType 也只读前 512 字节)。
if n == len(transparent1x1PNG) && bytes.Equal(header, transparent1x1PNG) {
return errImageProxyNonImageContent
}
return nil
}
// streamImageToCache 把上游响应体流式写入临时文件,校验确为图片后原子替换到
// cachePath。原图常有数兆字节,旧实现每次都要先整张读进内存再写盘,在小内存
// 主机上几个并发海报请求就能把内存顶满。
func (p *ImageProxy) streamImageToCache(cachePath, failPath string, body io.Reader) error {
if err := os.MkdirAll(filepath.Dir(cachePath), 0o750); err != nil {
p.warn("imageproxy: cache dir failed", err)
return errImageCacheUnavailable
}
tmp, err := os.CreateTemp(p.cacheDir, "img-*.tmp")
if err != nil {
p.warn("imageproxy: cache temp file failed", err)
return errImageCacheUnavailable
}
tmpName := tmp.Name()
discard := func() { _ = os.Remove(tmpName) }
written, copyErr := io.Copy(tmp, io.LimitReader(body, imageProxyMaxDownloadBytes))
closeErr := tmp.Close()
if copyErr != nil || closeErr != nil {
discard()
if copyErr != nil {
return copyErr
}
return fmt.Errorf("%w: %v", errImageCacheCommitFailed, closeErr)
}
if written == 0 {
discard()
return errors.New("upstream image body is empty")
}
if err := cachedImageFileValid(tmpName); err != nil {
discard()
return err
}
if err := os.Rename(tmpName, cachePath); err != nil {
discard()
return fmt.Errorf("%w: %v", errImageCacheCommitFailed, err)
}
_ = os.Remove(failPath)
return nil
}
func serveCachedImageFile(w http.ResponseWriter, r *http.Request, key, cachePath string) bool {
return serveImageFile(w, r, key, cachePath, imageBrowserCacheControl)
}
@@ -131,13 +230,6 @@ func imageFileETag(key string, stat os.FileInfo) string {
return `"img-` + hex.EncodeToString(sum[:8]) + "-" + strconv.FormatInt(stat.Size(), 16) + "-" + strconv.FormatInt(stat.ModTime().Unix(), 16) + `"`
}
func freshNegativeImageCache(failPath string) bool {
if _, err := os.Stat(failPath); err == nil {
_ = os.Remove(failPath)
}
return false
}
func (p *ImageProxy) markImageFetchFailed(failPath string) {
if err := os.MkdirAll(filepath.Dir(failPath), 0o750); err != nil {
return
+99 -93
View File
@@ -4,53 +4,80 @@ import (
"bytes"
"context"
"errors"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"go.uber.org/zap"
)
var errImageProxyRequestSetup = errors.New("image proxy request setup failed")
var errImageProxyNonImageContent = errors.New("upstream returned non-image content")
// prefetchCardResizeOptions 对应前端 ARTWORK.posterCard(见 web/src/api/client.ts):
// 卡片是海报墙最常请求的档位,刮削阶段预生成它能让首个列表请求直接命中缓存。
// 若前端调整该预设,这里只是白生成一份用不到的档位(约几十 KB),不影响正确性。
var prefetchCardResizeOptions = imageResizeOptions{MaxWidth: 480, MaxHeight: 600, Quality: 80}
func (p *ImageProxy) PrefetchRemote(ctx context.Context, raw string) error {
_, _, err := p.Fetch(ctx, raw)
return err
}
// PrefetchCardVariant 预取原图后再离线生成卡片档位的缩略图。刮削是后台任务,
// 在这里做掉解码可以把海报墙首屏的 CPU 抖动移到请求路径之外。
//
// 预生成失败不影响预取结果:预取的成功含义是「图片可达」(刮削据此决定是否
// 替换旧图),而缩略图只是优化,客户端首次请求时会自己生成。
func (p *ImageProxy) PrefetchCardVariant(ctx context.Context, raw string) error {
if err := p.PrefetchRemote(ctx, raw); err != nil {
return err
}
if !isHTTPish(raw) {
return nil
}
_, cachePath, _ := p.remoteImageCachePathsForValidated(raw)
if err := p.ensureResizeCache(ctx, cachePath, prefetchCardResizeOptions); err != nil {
p.warn("imageproxy: prefetch card variant failed", err)
}
return nil
}
func (p *ImageProxy) RemoveCached(raw string) error {
if !isHTTPish(raw) {
return nil
}
_, cachePath, failPath, err := p.remoteImageCachePaths(raw)
if err != nil {
if _, err := p.validateURL(raw); err != nil {
return nil
}
if err := os.Remove(cachePath); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
var firstErr error
for _, paths := range p.remoteImageCachePathsEveryPool(raw) {
cachePath, failPath := paths[1], paths[2]
if err := os.Remove(cachePath); err != nil && !errors.Is(err, os.ErrNotExist) {
firstErr = err
}
if err := os.Remove(failPath); err != nil && !errors.Is(err, os.ErrNotExist) {
firstErr = err
}
}
if err := os.Remove(failPath); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
return nil
return firstErr
}
func (p *ImageProxy) RemoveFailed(raw string) error {
if !isHTTPish(raw) {
return nil
}
_, _, failPath, err := p.remoteImageCachePaths(raw)
if err != nil {
if _, err := p.validateURL(raw); err != nil {
return nil
}
if err := os.Remove(failPath); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
var firstErr error
for _, paths := range p.remoteImageCachePathsEveryPool(raw) {
failPath := paths[2]
if err := os.Remove(failPath); err != nil && !errors.Is(err, os.ErrNotExist) {
firstErr = err
}
}
return nil
return firstErr
}
// Serve writes the requested image to w. Caller is expected to validate
@@ -87,7 +114,17 @@ func (p *ImageProxy) serveRemoteImage(ctx context.Context, w http.ResponseWriter
return err
}
host := strings.ToLower(u.Host)
key, cachePath, failPath := p.remoteImageCachePathsForValidated(raw)
// 已配置的远程 Emby 挂载:把尺寸直接转发给远端生成缩略图,缓存键也用
// 带尺寸的地址,这样不同尺寸各自缓存互不覆盖。
fetchURL := p.upstreamImageFetchURL(raw, opts)
// 尺寸被转发给挂载的远端时,上游返回的就是客户端要的最终尺寸成品,归入
// 成品池长期保留;其他上游返回的是原图,只是生成各种尺寸的原料,归入
// 原图池(小配额 + 短保留)。
pool := imageOriginalCacheSubdir
if fetchURL != raw {
pool = imageRenditionCacheSubdir
}
key, cachePath, failPath := p.remoteImageCachePathsInPool(fetchURL, pool)
forceRefresh := r.URL.Query().Get("refresh") != ""
p.removeUnusableImageCache(cachePath, failPath)
if !forceRefresh && p.serveCachedImage(w, r, key, cachePath, opts) {
@@ -95,7 +132,7 @@ func (p *ImageProxy) serveRemoteImage(ctx context.Context, w http.ResponseWriter
}
// No negative caching: a previously failed fetch is retried on every
// subsequent request, so the image recovers as soon as upstream does.
data, ctype, contentLength, err := p.fetchAndCacheRemoteImageShared(ctx, raw, host, cachePath, failPath)
result, err := p.fetchAndCacheRemoteImageShared(ctx, fetchURL, host, cachePath, failPath)
if err != nil {
if forceRefresh && p.serveCachedImage(w, r, key, cachePath, opts) {
return nil
@@ -111,17 +148,16 @@ func (p *ImageProxy) serveRemoteImage(ctx context.Context, w http.ResponseWriter
if opts.active() && p.serveResizedFromFile(w, r, cachePath, opts) {
return nil
}
w.Header().Set("Content-Type", ctype)
if contentLength != "" {
w.Header().Set("Content-Length", contentLength)
// 缓存目录不可写时的内存兜底:图片只在本次响应里直出,不落盘。
if len(result.data) > 0 {
w.Header().Set("Content-Type", result.contentType)
w.Header().Set("Cache-Control", imageBrowserCacheControl)
http.ServeContent(w, r, key, time.Now(), bytes.NewReader(result.data))
return nil
}
modTime := time.Now()
if stat, err := os.Stat(cachePath); err == nil && stat.Size() > 0 {
modTime = stat.ModTime()
w.Header().Set("ETag", imageFileETag(key, stat))
if !p.serveCachedImage(w, r, key, cachePath, opts) {
serveCachedPlaceholder(w)
}
w.Header().Set("Cache-Control", imageBrowserCacheControl)
http.ServeContent(w, r, key, modTime, bytes.NewReader(data))
return nil
}
@@ -137,64 +173,32 @@ func (p *ImageProxy) serveCachedImage(w http.ResponseWriter, r *http.Request, ke
func (p *ImageProxy) removeUnusableImageCache(cachePath, failPath string) {
// 只读取文件头判断缓存是否可用。旧实现每次命中远程图片缓存都会把整个
// 原图读进内存再丢弃,电视端批量加载海报时会产生大量无意义的磁盘 I/O。
file, err := os.Open(cachePath) // #nosec G304 -- cachePath is SHA-derived under cacheDir.
if err != nil {
if _, err := os.Stat(cachePath); err != nil {
return
}
stat, err := file.Stat()
if err != nil || stat.IsDir() || stat.Size() <= 0 {
_ = file.Close()
if err := cachedImageFileValid(cachePath); err != nil {
_ = os.Remove(cachePath)
_ = os.Remove(failPath)
return
}
headerSize := 512
if stat.Size() < int64(headerSize) {
headerSize = int(stat.Size())
}
header := make([]byte, headerSize)
n, readErr := io.ReadFull(file, header)
_ = file.Close()
if readErr != nil && readErr != io.ErrUnexpectedEOF {
_ = os.Remove(cachePath)
_ = os.Remove(failPath)
return
}
header = header[:n]
// A transparent placeholder is exactly 67 bytes; checking the header alone
// is enough for the normal image cache entries (they are much larger but
// detectContentType only inspects the same leading 512 bytes anyway).
// Close the handle before deleting: Windows refuses to delete an open file.
if n > 0 && isImageContentType(detectContentType(header)) &&
!(n == len(transparent1x1PNG) && bytes.Equal(header, transparent1x1PNG)) {
return
}
_ = os.Remove(cachePath)
_ = os.Remove(failPath)
}
func (p *ImageProxy) fetchAndCacheRemoteImage(ctx context.Context, raw, host, cachePath, failPath string) ([]byte, string, string, error) {
if err := os.MkdirAll(p.cacheDir, 0o750); err != nil {
p.log.Warn("imageproxy: mkdir failed", zap.String("dir", p.cacheDir), zap.Error(err))
return nil, "", "", errImageProxyRequestSetup
}
func (p *ImageProxy) fetchAndCacheRemoteImage(ctx context.Context, raw, host, cachePath, failPath string) (remoteImageFetchResult, error) {
var lastErr error
for _, candidate := range p.remoteImageFetchClients() {
data, ctype, contentLength, err := p.fetchRemoteImageOnce(ctx, raw, host, candidate)
for _, candidate := range p.remoteImageFetchClients(host) {
result, err := p.fetchRemoteImageOnce(ctx, raw, host, candidate, cachePath, failPath)
if err == nil {
p.writeImageCache(cachePath, failPath, "img-*.tmp", data)
return data, ctype, contentLength, nil
return result, nil
}
if errors.Is(err, errImageProxyRequestSetup) {
return nil, "", "", err
return remoteImageFetchResult{}, err
}
lastErr = err
}
if p.canUseExternalImageFallback() && isDoubanImageHost(host) {
data, ctype, contentLength, err := fetchRemoteImageWithCurl(ctx, raw, host)
data, ctype, _, err := fetchRemoteImageWithCurl(ctx, raw, host)
if err == nil {
p.writeImageCache(cachePath, failPath, "img-*.tmp", data)
return data, ctype, contentLength, nil
return remoteImageFetchResult{data: data, contentType: ctype}, nil
}
logImageFetchError(p.log, "imageproxy: curl fallback failed", host, "curl", err)
lastErr = err
@@ -203,41 +207,27 @@ func (p *ImageProxy) fetchAndCacheRemoteImage(ctx context.Context, raw, host, ca
if lastErr == nil {
lastErr = errors.New("upstream image fetch failed")
}
return nil, "", "", redactSensitiveError(lastErr)
}
type sharedRemoteImageResult struct {
data []byte
contentType string
contentLength string
return remoteImageFetchResult{}, redactSensitiveError(lastErr)
}
// fetchAndCacheRemoteImageShared coalesces concurrent requests for the same
// upstream image. A poster can appear in the hero, a shelf and the detail page
// at the same time; without this guard every resize variant may fetch the same
// original before the first cache write finishes.
func (p *ImageProxy) fetchAndCacheRemoteImageShared(ctx context.Context, raw, host, cachePath, failPath string) ([]byte, string, string, error) {
func (p *ImageProxy) fetchAndCacheRemoteImageShared(ctx context.Context, raw, host, cachePath, failPath string) (remoteImageFetchResult, error) {
value, err, _ := p.fetchGroup.Do(cachePath, func() (any, error) {
loadCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 45*time.Second)
defer cancel()
data, contentType, contentLength, err := p.fetchAndCacheRemoteImage(loadCtx, raw, host, cachePath, failPath)
if err != nil {
return nil, err
}
return sharedRemoteImageResult{
data: data,
contentType: contentType,
contentLength: contentLength,
}, nil
return p.fetchAndCacheRemoteImage(loadCtx, raw, host, cachePath, failPath)
})
if err != nil {
return nil, "", "", err
return remoteImageFetchResult{}, err
}
result, ok := value.(sharedRemoteImageResult)
result, ok := value.(remoteImageFetchResult)
if !ok {
return nil, "", "", errors.New("upstream image fetch failed")
return remoteImageFetchResult{}, errors.New("upstream image fetch failed")
}
return result.data, result.contentType, result.contentLength, nil
return result, nil
}
// Fetch pulls a remote image and returns bytes plus Content-Type using cache.
@@ -259,13 +249,29 @@ func (p *ImageProxy) Fetch(ctx context.Context, raw string) ([]byte, string, err
}
// No negative caching: a previously failed fetch is retried on every
// subsequent request, so the image recovers as soon as upstream does.
data, ctype, _, err := p.fetchAndCacheRemoteImage(ctx, raw, host, cachePath, failPath)
return data, ctype, err
result, err := p.fetchAndCacheRemoteImage(ctx, raw, host, cachePath, failPath)
if err != nil {
return nil, "", err
}
// 正常路径只落盘,这里按需读回(调用方需要字节)。
if len(result.data) > 0 {
return result.data, result.contentType, nil
}
data, err := os.ReadFile(cachePath) // #nosec G304 -- cachePath is SHA-derived under cacheDir.
if err != nil || len(data) == 0 {
return nil, "", errors.New("cached image is unreadable")
}
return data, detectContentType(data), nil
}
// writeImageCache atomically writes the fetched original. The global mutex is
// deliberately not held: os.CreateTemp already yields a unique name and
// os.Rename is atomic, so the lock only serialized multi-megabyte disk writes
// and made one poster's write block every other image in flight.
func (p *ImageProxy) writeImageCache(cachePath, failPath, pattern string, data []byte) {
p.mu.Lock()
defer p.mu.Unlock()
if err := os.MkdirAll(filepath.Dir(cachePath), 0o750); err != nil {
return
}
tmp, tmpErr := os.CreateTemp(p.cacheDir, pattern)
if tmpErr != nil {
return
+56 -22
View File
@@ -18,23 +18,32 @@ type remoteImageFetchClient struct {
client *http.Client
}
func (p *ImageProxy) remoteImageFetchClients() []remoteImageFetchClient {
func (p *ImageProxy) remoteImageFetchClients(host string) []remoteImageFetchClient {
client := p.client
if client == nil {
client = NewExternalHTTPClient(30 * time.Second)
}
clients := []remoteImageFetchClient{{name: "default", client: client}}
if _, ok := client.Transport.(*http.Transport); ok {
timeout := client.Timeout
if timeout <= 0 {
timeout = 30 * time.Second
}
clients = append(clients, remoteImageFetchClient{
name: "direct",
client: &http.Client{Timeout: timeout, Transport: NewInternalTransport()},
})
if _, ok := client.Transport.(*http.Transport); !ok {
return []remoteImageFetchClient{{name: "default", client: client}}
}
return clients
timeout := client.Timeout
if timeout <= 0 {
timeout = 30 * time.Second
}
direct := p.directClient
if direct == nil {
direct = &http.Client{Timeout: timeout, Transport: NewInternalTransport()}
}
directCandidate := remoteImageFetchClient{name: "direct", client: direct}
defaultCandidate := remoteImageFetchClient{name: "default", client: client}
// Hosts the user configured themselves — remote Emby mounts and their image
// endpoints — are reached over the LAN or a dedicated line. Routing those
// through the OS/env proxy first costs a failed attempt before every single
// image, so try the direct client first for them.
if p.isAllowedRemoteHost(host) {
return []remoteImageFetchClient{directCandidate, defaultCandidate}
}
return []remoteImageFetchClient{defaultCandidate, directCandidate}
}
func (p *ImageProxy) canUseExternalImageFallback() bool {
@@ -45,38 +54,63 @@ func (p *ImageProxy) canUseExternalImageFallback() bool {
return ok
}
func (p *ImageProxy) fetchRemoteImageOnce(ctx context.Context, raw, host string, candidate remoteImageFetchClient) ([]byte, string, string, error) {
// remoteImageFetchResult 是一次成功拉取的结果。正常路径图片已经流式落盘
// (data 为空,调用方从缓存文件下发/缩放);只有缓存目录不可写、退回内存
// 缓冲时才带 data,保证图片仍能发给客户端。
type remoteImageFetchResult struct {
data []byte
contentType string
}
// fetchRemoteImageOnce 拉取一次上游图片并写入 cachePath。响应体直接流式落盘,
// 不再整张读进内存。
func (p *ImageProxy) fetchRemoteImageOnce(ctx context.Context, raw, host string, candidate remoteImageFetchClient, cachePath, failPath string) (remoteImageFetchResult, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil)
if err != nil {
p.log.Warn("imageproxy: build request failed", zap.String("url", redactSensitiveURL(raw)), zap.Error(redactSensitiveError(err)))
return nil, "", "", errImageProxyRequestSetup
return remoteImageFetchResult{}, errImageProxyRequestSetup
}
applyRemoteImageHeaders(req, host, raw)
resp, err := candidate.client.Do(req)
if err != nil {
logImageFetchError(p.log, "imageproxy: upstream fetch failed", host, candidate.name, err)
return nil, "", "", err
return remoteImageFetchResult{}, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
p.log.Warn("imageproxy: upstream returned non-OK", zap.String("host", host), zap.String("client", candidate.name), zap.String("status", resp.Status))
return nil, "", "", errors.New("upstream returned " + resp.Status)
return remoteImageFetchResult{}, errors.New("upstream returned " + resp.Status)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20))
if err := p.streamImageToCache(cachePath, failPath, resp.Body); err != nil {
if errors.Is(err, errImageCacheUnavailable) {
// 缓存目录不可写:响应体还没读,退回内存缓冲。
return p.bufferRemoteImage(resp, host, candidate.name, cachePath, failPath)
}
logImageFetchError(p.log, "imageproxy: stream image failed", host, candidate.name, err)
return remoteImageFetchResult{}, err
}
return remoteImageFetchResult{}, nil
}
// bufferRemoteImage 在缓存不可用时把响应体读进内存,校验为图片后尽力写入
// 缓存(失败也不影响本次下发)。
func (p *ImageProxy) bufferRemoteImage(resp *http.Response, host, client, cachePath, failPath string) (remoteImageFetchResult, error) {
data, err := io.ReadAll(io.LimitReader(resp.Body, imageProxyMaxDownloadBytes))
if err != nil || len(data) == 0 {
p.log.Warn("imageproxy: read upstream body failed", zap.String("host", host), zap.String("client", candidate.name), zap.Error(redactSensitiveError(err)))
p.log.Warn("imageproxy: read upstream body failed", zap.String("host", host), zap.String("client", client), zap.Error(redactSensitiveError(err)))
if err == nil {
err = errors.New("upstream image body is empty")
}
return nil, "", "", err
return remoteImageFetchResult{}, err
}
ctype, ok := validImageContentType(data)
if !ok {
p.log.Warn("imageproxy: upstream returned non-image content", zap.String("host", host), zap.String("client", candidate.name), zap.String("content_type", resp.Header.Get("Content-Type")))
return nil, "", "", errImageProxyNonImageContent
p.log.Warn("imageproxy: upstream returned non-image content", zap.String("host", host), zap.String("client", client), zap.String("content_type", resp.Header.Get("Content-Type")))
return remoteImageFetchResult{}, errImageProxyNonImageContent
}
return data, ctype, resp.Header.Get("Content-Length"), nil
p.writeImageCache(cachePath, failPath, "img-*.tmp", data)
return remoteImageFetchResult{data: data, contentType: ctype}, nil
}
func logImageFetchError(log *zap.Logger, message, host, client string, err error) {
+188
View File
@@ -0,0 +1,188 @@
package service
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"sync/atomic"
"testing"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
)
// newStreamImageProxy 构造一个允许访问指定测试上游的图片代理。
func newStreamImageProxy(t *testing.T, upstreamURL string) *ImageProxy {
t.Helper()
parsed, err := url.Parse(upstreamURL)
if err != nil {
t.Fatalf("parse upstream url: %v", err)
}
proxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}, zap.NewNop())
proxy.SetAllowedRemoteHostsProvider(func() []string { return []string{parsed.Host} })
return proxy
}
// bigTestJPEG 在合法 JPEG 后面补一串数据,用来验证大图是流式落盘而不是
// 整张读进内存(剧照/原图常有数兆字节)。
func bigTestJPEG(t *testing.T, extraBytes int) []byte {
t.Helper()
out := make([]byte, 0, len(testJPEG)+extraBytes)
out = append(out, testJPEG...)
out = append(out, bytes.Repeat([]byte{0x5a}, extraBytes)...)
return out
}
// TestImageProxyStreamsRemoteImageIntoCacheFile 远程原图必须直接流式写到
// 缓存文件,后续请求由缓存文件服务(上游只被请求一次)。
func TestImageProxyStreamsRemoteImageIntoCacheFile(t *testing.T) {
payload := bigTestJPEG(t, 3<<20)
var calls int32
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&calls, 1)
w.Header().Set("Content-Type", "image/jpeg")
_, _ = w.Write(payload)
}))
defer upstream.Close()
proxy := newStreamImageProxy(t, upstream.URL)
raw := upstream.URL + "/poster.jpg"
rec := httptest.NewRecorder()
if err := proxy.Serve(t.Context(), rec, httptest.NewRequest(http.MethodGet, "/api/img", nil), raw); err != nil {
t.Fatalf("Serve failed: %v", err)
}
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if !bytes.Equal(rec.Body.Bytes(), payload) {
t.Fatalf("body length = %d, want %d", rec.Body.Len(), len(payload))
}
_, cachePath, failPath := proxy.remoteImageCachePathsForValidated(raw)
stat, err := os.Stat(cachePath)
if err != nil {
t.Fatalf("expected the image to be streamed to %s: %v", cachePath, err)
}
if stat.Size() != int64(len(payload)) {
t.Fatalf("cached size = %d, want %d", stat.Size(), len(payload))
}
if _, err := os.Stat(failPath); err == nil {
t.Fatalf("unexpected failure marker at %s", failPath)
}
if entries, err := os.ReadDir(filepath.Dir(cachePath)); err == nil {
for _, entry := range entries {
if filepath.Ext(entry.Name()) == ".tmp" {
t.Fatalf("temporary file left behind: %s", entry.Name())
}
}
}
// 第二次请求命中缓存文件,不再回源。
second := httptest.NewRecorder()
if err := proxy.Serve(t.Context(), second, httptest.NewRequest(http.MethodGet, "/api/img", nil), raw); err != nil {
t.Fatalf("second Serve failed: %v", err)
}
if !bytes.Equal(second.Body.Bytes(), payload) {
t.Fatal("cached response body mismatch")
}
if got := atomic.LoadInt32(&calls); got != 1 {
t.Fatalf("upstream calls = %d, want 1 (second request must be served from cache)", got)
}
}
// TestImageProxyRejectsNonImageUpstreamWithoutCaching 流式写入必须在提交前
// 校验内容,否则错误页会被永久缓存成图片。
func TestImageProxyRejectsNonImageUpstreamWithoutCaching(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte("<html><body>not an image</body></html>"))
}))
defer upstream.Close()
proxy := newStreamImageProxy(t, upstream.URL)
raw := upstream.URL + "/poster.jpg"
rec := httptest.NewRecorder()
if err := proxy.Serve(t.Context(), rec, httptest.NewRequest(http.MethodGet, "/api/img", nil), raw); err != nil {
t.Fatalf("Serve failed: %v", err)
}
if rec.Body.Len() != len(transparent1x1PNG) {
t.Fatalf("body length = %d, want placeholder %d", rec.Body.Len(), len(transparent1x1PNG))
}
_, cachePath, failPath := proxy.remoteImageCachePathsForValidated(raw)
if _, err := os.Stat(cachePath); err == nil {
t.Fatal("non-image response must not be cached")
}
if _, err := os.Stat(failPath); err != nil {
t.Fatalf("expected a failure marker next to the cache entry: %v", err)
}
}
// TestImageProxyServesRemoteImageWhenCacheDirUnwritable 缓存目录不可写时
// (磁盘只读/满)必须退回内存缓冲,不能让所有图片变成占位图。
func TestImageProxyServesRemoteImageWhenCacheDirUnwritable(t *testing.T) {
payload := testJPEG
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/jpeg")
_, _ = w.Write(payload)
}))
defer upstream.Close()
// 把一个普通文件当作目录的父级,MkdirAll 必然失败。
root := t.TempDir()
blocker := filepath.Join(root, "blocker")
if err := os.WriteFile(blocker, []byte("x"), 0o644); err != nil {
t.Fatalf("write blocker: %v", err)
}
proxy := newStreamImageProxy(t, upstream.URL)
proxy.cacheDir = filepath.Join(blocker, "cache", "images")
raw := upstream.URL + "/poster.jpg"
rec := httptest.NewRecorder()
if err := proxy.Serve(t.Context(), rec, httptest.NewRequest(http.MethodGet, "/api/img", nil), raw); err != nil {
t.Fatalf("Serve failed: %v", err)
}
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
body, err := io.ReadAll(rec.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
if !bytes.Equal(body, payload) {
t.Fatalf("body length = %d, want %d", len(body), len(payload))
}
}
// TestImageProxyFetchReturnsCachedBytes Fetch 仍需返回字节(刮削写元数据用),
// 内容来自刚写入的缓存文件。
func TestImageProxyFetchReturnsCachedBytes(t *testing.T) {
payload := bigTestJPEG(t, 256<<10)
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/jpeg")
_, _ = w.Write(payload)
}))
defer upstream.Close()
proxy := newStreamImageProxy(t, upstream.URL)
raw := upstream.URL + "/poster.jpg"
data, ctype, err := proxy.Fetch(t.Context(), raw)
if err != nil {
t.Fatalf("Fetch failed: %v", err)
}
if !bytes.Equal(data, payload) {
t.Fatalf("fetched %d bytes, want %d", len(data), len(payload))
}
if ctype != "image/jpeg" {
t.Fatalf("content type = %q, want image/jpeg", ctype)
}
}
+63
View File
@@ -0,0 +1,63 @@
package service
import (
"net/url"
"strconv"
"strings"
)
// imageCacheKeyURL normalizes an upstream image URL before it is hashed into a
// disk cache key. Credential query parameters are dropped: a remote Emby token
// is per-account, so removing it cannot make two different images collide, but
// it stops a token rotation from invalidating every cached poster at once.
func imageCacheKeyURL(raw string) string {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil || u.Host == "" {
return raw
}
q := u.Query()
dropped := false
for key := range q {
switch strings.ToLower(key) {
case "api_key", "apikey", "x-emby-token", "x-mediabrowser-token":
q.Del(key)
dropped = true
}
}
if !dropped {
return raw
}
u.RawQuery = q.Encode()
return u.String()
}
// upstreamImageFetchURL forwards the client's thumbnail request to a configured
// remote Emby mount. The remote server can produce the thumbnail itself from
// maxWidth/maxHeight/quality, so the proxy transfers a few dozen KB instead of
// the full-size original and skips the local decode+scale entirely (measured at
// ~400ms for a single 2892×4096 poster).
//
// Other upstreams (TMDb, Douban, adult sites, ...) do not honour these
// parameters, so their originals are still fetched and scaled locally.
func (p *ImageProxy) upstreamImageFetchURL(raw string, opts imageResizeOptions) string {
if p == nil || !opts.active() {
return raw
}
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
return raw
}
if !p.isAllowedRemoteHost(u.Hostname()) {
return raw
}
q := u.Query()
if opts.MaxWidth > 0 {
q.Set("maxWidth", strconv.Itoa(opts.MaxWidth))
}
if opts.MaxHeight > 0 {
q.Set("maxHeight", strconv.Itoa(opts.MaxHeight))
}
q.Set("quality", strconv.Itoa(opts.encodingQuality()))
u.RawQuery = q.Encode()
return u.String()
}
@@ -0,0 +1,198 @@
package service
import (
"bytes"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
)
func newUpstreamImageProxy(t *testing.T, allowedHosts ...string) *ImageProxy {
t.Helper()
proxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}, zap.NewNop())
if len(allowedHosts) > 0 {
proxy.SetAllowedRemoteHostsProvider(func() []string { return allowedHosts })
}
return proxy
}
func TestUpstreamImageFetchURLForwardsResizeToConfiguredMount(t *testing.T) {
proxy := newUpstreamImageProxy(t, "192.168.1.50:8096")
raw := "http://192.168.1.50:8096/emby/Items/abc/Images/Primary?api_key=secret"
got := proxy.upstreamImageFetchURL(raw, imageResizeOptions{MaxWidth: 480, MaxHeight: 600, Quality: 82})
u, err := url.Parse(got)
if err != nil {
t.Fatal(err)
}
q := u.Query()
if q.Get("maxWidth") != "480" || q.Get("maxHeight") != "600" || q.Get("quality") != "82" {
t.Fatalf("forwarded query = %q, want maxWidth=480 maxHeight=600 quality=82", u.RawQuery)
}
if q.Get("api_key") != "secret" {
t.Fatalf("api_key = %q, want the credential preserved", q.Get("api_key"))
}
if u.Path != "/emby/Items/abc/Images/Primary" {
t.Fatalf("path = %q, want the item image path unchanged", u.Path)
}
// The default quality is applied when the client does not send one.
got = proxy.upstreamImageFetchURL(raw, imageResizeOptions{MaxWidth: 160})
if !strings.Contains(got, "quality="+strconv.Itoa(imageResizeDefaultQuality)) {
t.Fatalf("url = %q, want the default encoding quality forwarded", got)
}
}
func TestUpstreamImageFetchURLLeavesOtherUpstreamsAlone(t *testing.T) {
proxy := newUpstreamImageProxy(t, "192.168.1.50:8096")
opts := imageResizeOptions{MaxWidth: 480, MaxHeight: 600, Quality: 82}
// Not a configured mount: TMDb/Douban do not honour these parameters, so the
// proxy must keep fetching their originals and resize locally.
tmdb := "https://image.tmdb.org/t/p/original/poster.jpg"
if got := proxy.upstreamImageFetchURL(tmdb, opts); got != tmdb {
t.Fatalf("url = %q, want %q unchanged", got, tmdb)
}
// Configured mount but no size requested: keep the full original.
mount := "http://192.168.1.50:8096/emby/Items/abc/Images/Primary?api_key=secret"
if got := proxy.upstreamImageFetchURL(mount, imageResizeOptions{}); got != mount {
t.Fatalf("url = %q, want %q unchanged", got, mount)
}
}
func TestRemoteImageCacheKeyIgnoresCredentialRotation(t *testing.T) {
proxy := newUpstreamImageProxy(t)
first := "http://192.168.1.50:8096/emby/Items/abc/Images/Primary?api_key=old-token"
second := "http://192.168.1.50:8096/emby/Items/abc/Images/Primary?api_key=new-token"
_, firstPath, _ := proxy.remoteImageCachePathsForValidated(first)
_, secondPath, _ := proxy.remoteImageCachePathsForValidated(second)
if firstPath != secondPath {
t.Fatalf("token rotation changed the cache key: %q vs %q", firstPath, secondPath)
}
other := "http://192.168.1.50:8096/emby/Items/zzz/Images/Primary?api_key=new-token"
_, otherPath, _ := proxy.remoteImageCachePathsForValidated(other)
if otherPath == secondPath {
t.Fatal("different items must not share a cache key")
}
// A generic `token` parameter can be part of a signed URL's identity, so it
// must stay in the key. Only credential-ish names are dropped.
signed := "http://cdn.example.com/img/1.jpg?token=aaa"
signedOther := "http://cdn.example.com/img/1.jpg?token=bbb"
_, signedPath, _ := proxy.remoteImageCachePathsForValidated(signed)
_, signedOtherPath, _ := proxy.remoteImageCachePathsForValidated(signedOther)
if signedPath == signedOtherPath {
t.Fatal("generic token query parameters must remain part of the cache key")
}
}
func TestRemoteImageFetchClientsPreferDirectForConfiguredMount(t *testing.T) {
proxy := newUpstreamImageProxy(t, "192.168.1.50:8096")
mountClients := proxy.remoteImageFetchClients("192.168.1.50:8096")
if len(mountClients) != 2 || mountClients[0].name != "direct" || mountClients[1].name != "default" {
t.Fatalf("mount client order = %+v, want direct first", clientNames(mountClients))
}
if mountClients[0].client != proxy.directClient {
t.Fatal("direct fetches must reuse the shared no-proxy client")
}
cdnClients := proxy.remoteImageFetchClients("image.tmdb.org")
if len(cdnClients) != 2 || cdnClients[0].name != "default" || cdnClients[1].name != "direct" {
t.Fatalf("cdn client order = %+v, want default first", clientNames(cdnClients))
}
}
func clientNames(clients []remoteImageFetchClient) []string {
out := make([]string, 0, len(clients))
for _, c := range clients {
out = append(out, c.name)
}
return out
}
// TestServeRemoteImageForwardsResizeAndCachesPerSize is the regression test for
// sluggish artwork on mounted Emby libraries: MeBox used to download the remote
// original and scale it locally for every requested size. The size must now be
// forwarded to the mount, and each size must get its own cache entry.
func TestServeRemoteImageForwardsResizeAndCachesPerSize(t *testing.T) {
var mu sync.Mutex
queries := []url.Values{}
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
queries = append(queries, r.URL.Query())
mu.Unlock()
w.Header().Set("Content-Type", "image/jpeg")
_, _ = w.Write(testJPEG)
}))
defer upstream.Close()
u, err := url.Parse(upstream.URL)
if err != nil {
t.Fatal(err)
}
proxy := newUpstreamImageProxy(t, u.Host)
raw := upstream.URL + "/emby/Items/abc/Images/Primary?api_key=secret"
serve := func(t *testing.T, query string) *httptest.ResponseRecorder {
t.Helper()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/img?"+query, nil)
if err := proxy.Serve(t.Context(), rec, req, raw); err != nil {
t.Fatal(err)
}
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
return rec
}
rec := serve(t, "maxWidth=480&maxHeight=600&quality=82")
if got := rec.Body.Bytes(); !bytes.Equal(got, testJPEG) {
t.Fatalf("body = %x, want the upstream image", got)
}
mu.Lock()
first := append([]url.Values(nil), queries...)
mu.Unlock()
if len(first) != 1 {
t.Fatalf("upstream calls = %d, want 1", len(first))
}
if first[0].Get("maxWidth") != "480" || first[0].Get("maxHeight") != "600" || first[0].Get("quality") != "82" {
t.Fatalf("upstream query = %q, want the client's thumbnail request forwarded", first[0].Encode())
}
// Same size again: served from the disk cache, no second upstream call.
serve(t, "maxWidth=480&maxHeight=600&quality=82")
mu.Lock()
if len(queries) != 1 {
mu.Unlock()
t.Fatalf("upstream calls = %d, want 1 after a cache hit", len(queries))
}
mu.Unlock()
// A different size must be its own cache entry, not a re-use of the 480px file.
serve(t, "maxWidth=160&quality=60")
mu.Lock()
defer mu.Unlock()
if len(queries) != 2 {
t.Fatalf("upstream calls = %d, want 2 for two distinct sizes", len(queries))
}
if got := queries[1].Get("maxWidth"); got != "160" {
t.Fatalf("second upstream maxWidth = %q, want 160", got)
}
if got := queries[1].Get("quality"); got != "60" {
t.Fatalf("second upstream quality = %q, want 60", got)
}
}
+175 -14
View File
@@ -34,7 +34,16 @@ const (
// N 像素的图在内存中约需 4N 字节;没有上限时,一张异常的超大图
// 就能在多张并发缩略图请求下打爆小内存主机。超过该上限时直接
// 回退为原图直出,宁可不缩放也不冒 OOM 风险。
imageResizeMaxSourcePixels = 30_000_000
//
// 1200 万像素覆盖常见的高清海报(2892×4096 约 1180 万),单张解码
// 峰值约 48MB RGBA;原先的 3000 万在 2 核 2GB 的机器上意味着单张
// 就可能吃掉 120MB 以上,两个并发槽足以触发 OOM/大量换页。
imageResizeMaxSourcePixels = 12_000_000
// imageResizeCheapScaleRatio 是启用低成本插值的缩小比例阈值:目标尺寸
// 小于源图一半时,CatmullRom 的收益肉眼不可见,但耗时和临时缓冲明显
// 更高(电视端海报墙会同时请求几十张缩略图)。
imageResizeCheapScaleRatio = 0.5
// imageResizeCacheSubdir 存放缩放结果,与远程原图缓存分开放,
// 便于单独清理且不与原始字节流缓存互相覆盖。
@@ -156,7 +165,7 @@ func resizeImageData(data []byte, o imageResizeOptions) (out []byte, ctype strin
return nil, "", false, err
}
dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH))
draw.CatmullRom.Scale(dst, dst.Bounds(), src, src.Bounds(), draw.Src, nil)
resizeScaled(dst, src, cfg.Width, cfg.Height)
// 只有可能带透明的源格式才需要逐像素确认,避免 JPEG 的无谓遍历。
// 写实海报的 PNG 通常比等价 JPEG 大一个数量级,因此在确认不含透明
@@ -176,12 +185,42 @@ func resizeImageData(data []byte, o imageResizeOptions) (out []byte, ctype strin
return buf.Bytes(), "image/jpeg", false, nil
}
// isOpaqueImage 逐像素确认图像不含透明像素。
// useCheapScaleInterpolator 报告这次缩放是否该用低成本插值:目标尺寸在宽高
// 两个方向都缩到源图一半以下时,CatmullRom 的额外采样换来的观感差异不可见。
func useCheapScaleInterpolator(dstW, dstH, srcW, srcH int) bool {
if srcW <= 0 || srcH <= 0 || dstW <= 0 || dstH <= 0 {
return false
}
return float64(dstW)/float64(srcW) <= imageResizeCheapScaleRatio &&
float64(dstH)/float64(srcH) <= imageResizeCheapScaleRatio
}
// resizeScaled 把 src 缩放到 dst。大幅缩小时改用低成本插值:CatmullRom
// 与 ApproxBiLinear 在大比例缩小下观感差异看不出来,但前者要遍历更多
// 邻域样本,在 2 核机器上会明显拖慢海报墙的并发缩略图请求。
func resizeScaled(dst *image.RGBA, src image.Image, srcW, srcH int) {
if dst == nil {
return
}
bounds := dst.Bounds()
interp := draw.Interpolator(draw.CatmullRom)
if useCheapScaleInterpolator(bounds.Dx(), bounds.Dy(), srcW, srcH) {
interp = draw.ApproxBiLinear
}
interp.Scale(dst, bounds, src, src.Bounds(), draw.Src, nil)
}
// isOpaqueImage 逐像素确认图像不含透明像素。dst 已知是 *image.RGBA,用
// RGBAAt 直取字段可以避免 At() 的接口分派与颜色模型换算(PNG 海报每次
// 生成缩略图都要走一遍全图扫描)。
func isOpaqueImage(img *image.RGBA) bool {
if img == nil {
return false
}
bounds := img.Bounds()
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
for x := bounds.Min.X; x < bounds.Max.X; x++ {
if _, _, _, a := img.At(x, y).RGBA(); a != 0xffff {
if img.RGBAAt(x, y).A != 0xff {
return false
}
}
@@ -189,19 +228,91 @@ func isOpaqueImage(img *image.RGBA) bool {
return true
}
// resizeCacheKey 生成缩放结果的缓存键,覆盖源文件身份(路径 + 大小 +
// 修改时间)与全部影响输出的参数,源文件被替换后不会命中陈旧缩略图。
func (o imageResizeOptions) resizeCacheKey(sourceID string, stat os.FileInfo) string {
// resizeSourceKey 生成源图身份键:只覆盖源文件身份(路径 + 大小 + mtime),不含
// 目标尺寸。同一源图的所有尺寸档位共用这个前缀,才能互相列举与派生(小图直接
// 从已缓存的大图缩小,而不是重新解码原图)。
func (o imageResizeOptions) resizeSourceKey(sourceID string, stat os.FileInfo) string {
h := sha256.New()
_, _ = fmt.Fprintf(h, "v1|%s|%dx%d|q%d", sourceID, o.MaxWidth, o.MaxHeight, o.encodingQuality())
_, _ = fmt.Fprintf(h, "v2|%s", sourceID)
if stat != nil {
_, _ = fmt.Fprintf(h, "|%d|%d", stat.Size(), stat.ModTime().UnixNano())
}
return hex.EncodeToString(h.Sum(nil))
}
func (p *ImageProxy) resizeCachePath(key string) string {
return filepath.Join(p.cacheDir, imageResizeCacheSubdir, key+".img")
// resizeCacheKey 是「源图身份 + 档位」的稳定标识,用于 ETag 等需要区分档位的
// 场合(磁盘路径只用源图身份 + 文件名后缀)。
func (o imageResizeOptions) resizeCacheKey(sourceID string, stat os.FileInfo) string {
return o.resizeSourceKey(sourceID, stat) + "." + o.resizeVariantSuffix()
}
func (p *ImageProxy) resizeCachePath(sourceKey string, o imageResizeOptions) string {
return filepath.Join(p.cacheDir, imageResizeCacheSubdir, sourceKey+"."+o.resizeVariantSuffix()+".img")
}
// resizeVariantSuffix 把目标尺寸与质量写进缩放结果的文件名。这样同一源图的
// 各档尺寸可以被列举出来:请求小图时若已有更大的档位缓存,直接从它缩小即可,
// 不必再解码多兆字节的原图(解码 480×600 约 1MB RGBA,解码原图可达数十 MB)。
func (o imageResizeOptions) resizeVariantSuffix() string {
return strconv.Itoa(o.MaxWidth) + "x" + strconv.Itoa(o.MaxHeight) + "q" + strconv.Itoa(o.encodingQuality())
}
// parseResizeVariantSuffix 解析 "<sourceKey>.<w>x<h>q<q>.img" 文件名中的尺寸。
func parseResizeVariantSuffix(name, sourceKey string) (width, height int, ok bool) {
prefix := sourceKey + "."
if !strings.HasPrefix(name, prefix) || !strings.HasSuffix(name, ".img") {
return 0, 0, false
}
spec := strings.TrimSuffix(strings.TrimPrefix(name, prefix), ".img")
dimensions, quality, found := strings.Cut(spec, "q")
if !found {
return 0, 0, false
}
w, h, found := strings.Cut(dimensions, "x")
if !found {
return 0, 0, false
}
width, err := strconv.Atoi(w)
if err != nil || width < 0 {
return 0, 0, false
}
height, err = strconv.Atoi(h)
if err != nil || height < 0 {
return 0, 0, false
}
if _, err := strconv.Atoi(quality); err != nil {
return 0, 0, false
}
return width, height, true
}
// largerCachedVariant 找出同一源图已缓存的、能覆盖目标尺寸的最小档位(宽高
// 都不小于目标)。返回空串表示没有可用的档位,调用方回退到解码原图。
func (p *ImageProxy) largerCachedVariant(key string, o imageResizeOptions) string {
pattern := filepath.Join(p.cacheDir, imageResizeCacheSubdir, key+".*.img")
matches, err := filepath.Glob(pattern)
if err != nil {
return ""
}
best := ""
bestPixels := 0
for _, path := range matches {
width, height, ok := parseResizeVariantSuffix(filepath.Base(path), key)
if !ok {
continue
}
if width == o.MaxWidth && height == o.MaxHeight {
continue // 精确档位,本该在上面就命中
}
if width < o.MaxWidth || height < o.MaxHeight {
continue
}
pixels := width * height
if best == "" || pixels < bestPixels {
best, bestPixels = path, pixels
}
}
return best
}
// acquireResizeSlot bounds CPU-heavy decode/resize work. Returning false means
@@ -265,7 +376,7 @@ func (p *ImageProxy) serveResizedFromFile(w http.ResponseWriter, r *http.Request
// 缓存命中必须发生在读原图和解码之前。否则电视端每次刷新海报墙都会
// 把已经是缩略图缓存的原图重新解码、缩放一遍,造成明显的 CPU 抖动。
key := o.resizeCacheKey(srcPath, stat)
cachePath := p.resizeCachePath(key)
cachePath := p.resizeCachePath(o.resizeSourceKey(srcPath, stat), o)
if serveCachedImageFile(w, r, key, cachePath) {
return true
}
@@ -286,7 +397,15 @@ func (p *ImageProxy) serveResizedFromFile(w http.ResponseWriter, r *http.Request
return true
}
data, err := os.ReadFile(srcPath) // #nosec G304 -- srcPath comes from an allowed local path or a SHA-derived cache path.
// 优先从同一源图已缓存的大尺寸档位缩小:小图(如 160px 模糊占位图)
// 往往能直接由已缓存的卡片图派生,代价从“解码多兆字节原图”降为
// “解码几百 KB 的档位文件”。
sourcePath := srcPath
if derived := p.largerCachedVariant(o.resizeSourceKey(srcPath, stat), o); derived != "" {
sourcePath = derived
}
data, err := os.ReadFile(sourcePath) // #nosec G304 -- srcPath comes from an allowed local path or a SHA-derived cache path.
if err != nil {
return false
}
@@ -306,6 +425,48 @@ func (p *ImageProxy) serveResizedFromFile(w http.ResponseWriter, r *http.Request
return true
}
// ensureResizeCache 在请求路径之外预先生成某档缩略图(刮削预取用)。已存在
// 时直接返回,无需调用方再次解码。
func (p *ImageProxy) ensureResizeCache(ctx context.Context, srcPath string, o imageResizeOptions) error {
if p == nil || !o.active() {
return nil
}
stat, err := os.Stat(srcPath)
if err != nil || stat.IsDir() || stat.Size() <= 0 {
return errors.New("image source unavailable")
}
sourceKey := o.resizeSourceKey(srcPath, stat)
cachePath := p.resizeCachePath(sourceKey, o)
if _, err := os.Stat(cachePath); err == nil {
return nil
}
release, ok := p.acquireResizeSlotFor(ctx, stat.Size())
if !ok {
return errors.New("image resize slot unavailable")
}
defer release()
if _, err := os.Stat(cachePath); err == nil {
return nil
}
sourcePath := srcPath
if derived := p.largerCachedVariant(sourceKey, o); derived != "" {
sourcePath = derived
}
data, err := os.ReadFile(sourcePath) // #nosec G304 -- srcPath is an allowed local path or a SHA-derived cache path.
if err != nil {
return err
}
out, _, unchanged, err := resizeImageData(data, o)
if err != nil {
return err
}
if unchanged {
return nil // 源图已在目标尺寸内,客户端会直接使用原文件。
}
p.writeResizeCache(cachePath, out)
return nil
}
// compactImageSkipBytes 是“直接出原图”的体积上限。超过它的原图(多兆字节
// 的剧照、未压缩 sidecar)仍然走缩放,避免把大文件直接塞给电视端。
// ponytail: 200KB 覆盖这台机器上的典型海报(平均约 100KB);更大的图仍尝试缩放。
@@ -344,14 +505,14 @@ func isCompactWebImage(header []byte) bool {
}
// writeResizeCache 原子写入缩放结果;失败只记日志,不影响本次响应。
// 与 writeImageCache 一样不再持有全局锁:临时文件名唯一、rename 原子,
// 持锁只会把缩略图的写盘和原图的写盘串成一条队。
func (p *ImageProxy) writeResizeCache(cachePath string, data []byte) {
dir := filepath.Dir(cachePath)
if err := os.MkdirAll(dir, 0o750); err != nil {
p.warn("imageproxy: resize cache mkdir failed", err)
return
}
p.mu.Lock()
defer p.mu.Unlock()
tmp, err := os.CreateTemp(dir, "resized-*.tmp")
if err != nil {
p.warn("imageproxy: resize cache temp failed", err)
@@ -0,0 +1,113 @@
package service
import (
"bytes"
"errors"
"image"
"image/color"
"image/jpeg"
"testing"
)
// encodeTestJPEG 生成一张便于压缩的测试用 JPEG(比 PNG 更适合构造大尺寸样本)。
func encodeTestJPEG(t *testing.T, w, h int) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
img.Set(x, y, color.RGBA{R: uint8(x % 32 * 8), G: uint8(y % 32 * 8), B: 150, A: 255})
}
}
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 70}); err != nil {
t.Fatalf("encode test jpeg: %v", err)
}
return buf.Bytes()
}
// TestResizeImageDataRejectsOverPixelBudget 锁定解码像素预算:超过上限的
// 原图必须放弃缩放(回退原图直出),而不是在小内存主机上尝试解码。
func TestResizeImageDataRejectsOverPixelBudget(t *testing.T) {
// 12_000_000 像素预算之上:3000×5000 = 1500 万。
data := encodeTestJPEG(t, 3000, 5000)
if _, _, _, err := resizeImageData(data, imageResizeOptions{MaxWidth: 400}); !errors.Is(err, errImageResizeTooLarge) {
t.Fatalf("expected errImageResizeTooLarge, got %v", err)
}
}
// TestResizeImageDataAcceptsRealisticPoster 常见高清海报(2892×4096,约
// 1180 万像素)必须仍在预算之内,否则真实海报会退化成直出多兆字节原图。
func TestResizeImageDataAcceptsRealisticPoster(t *testing.T) {
data := encodeTestJPEG(t, 2892, 4096)
out, ctype, unchanged, err := resizeImageData(data, imageResizeOptions{MaxWidth: 480, MaxHeight: 600})
if err != nil {
t.Fatalf("resizeImageData failed: %v", err)
}
if unchanged {
t.Fatal("expected a resized result, got the original bytes")
}
if ctype != "image/jpeg" {
t.Fatalf("content type = %q, want image/jpeg", ctype)
}
cfg, _, err := image.DecodeConfig(bytes.NewReader(out))
if err != nil {
t.Fatalf("decode resized output: %v", err)
}
if cfg.Width != 424 || cfg.Height != 600 {
t.Fatalf("resized to %dx%d, want 424x600", cfg.Width, cfg.Height)
}
}
// TestUseCheapScaleInterpolator 大比例缩小走低成本插值,轻微缩小仍保持高质量。
func TestUseCheapScaleInterpolator(t *testing.T) {
cases := []struct {
name string
dstW, dstH, srcW, srcH int
want bool
}{
{"poster to card", 480, 600, 2892, 4096, true},
{"hero to strip", 480, 320, 1920, 1080, true},
{"exactly half is cheap", 960, 540, 1920, 1080, true},
{"slight shrink", 900, 540, 1000, 600, false},
{"upscale", 1200, 800, 600, 400, false},
{"zero source", 100, 100, 0, 0, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := useCheapScaleInterpolator(tc.dstW, tc.dstH, tc.srcW, tc.srcH); got != tc.want {
t.Fatalf("useCheapScaleInterpolator(%d,%d,%d,%d) = %v, want %v",
tc.dstW, tc.dstH, tc.srcW, tc.srcH, got, tc.want)
}
})
}
}
// TestIsOpaqueImageDetectsAlpha 透明度检测:全不透明才允许转 JPEG。
func TestIsOpaqueImageDetectsAlpha(t *testing.T) {
opaque := image.NewRGBA(image.Rect(0, 0, 4, 4))
for y := 0; y < 4; y++ {
for x := 0; x < 4; x++ {
opaque.Set(x, y, color.RGBA{R: 10, G: 20, B: 30, A: 255})
}
}
if !isOpaqueImage(opaque) {
t.Fatal("expected fully opaque image to be reported opaque")
}
withAlpha := image.NewRGBA(image.Rect(0, 0, 4, 4))
for y := 0; y < 4; y++ {
for x := 0; x < 4; x++ {
withAlpha.Set(x, y, color.RGBA{R: 10, G: 20, B: 30, A: 255})
}
}
withAlpha.Set(2, 2, color.RGBA{R: 10, G: 20, B: 30, A: 128})
if isOpaqueImage(withAlpha) {
t.Fatal("expected image with a translucent pixel to be reported non-opaque")
}
if isOpaqueImage(nil) {
t.Fatal("expected nil image to be reported non-opaque")
}
}
@@ -0,0 +1,184 @@
package service
import (
"bytes"
"image"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"go.uber.org/zap"
)
func TestParseResizeVariantSuffixRoundTrip(t *testing.T) {
opts := imageResizeOptions{MaxWidth: 480, MaxHeight: 600, Quality: 80}
name := "abc123." + opts.resizeVariantSuffix() + ".img"
width, height, ok := parseResizeVariantSuffix(name, "abc123")
if !ok {
t.Fatalf("expected %q to parse", name)
}
if width != 480 || height != 600 {
t.Fatalf("parsed %dx%d, want 480x600", width, height)
}
for _, bad := range []string{"abc123.img", "abc124.480x600q80.img", "abc123.480x600.img", "abc123.axbq80.img"} {
if _, _, ok := parseResizeVariantSuffix(bad, "abc123"); ok {
t.Fatalf("expected %q to be rejected", bad)
}
}
}
func TestLargerCachedVariantPicksSmallestCoveringVariant(t *testing.T) {
proxy := &ImageProxy{cacheDir: filepath.Join(t.TempDir(), "cache"), log: zap.NewNop()}
dir := filepath.Join(proxy.cacheDir, imageResizeCacheSubdir)
if err := os.MkdirAll(dir, 0o750); err != nil {
t.Fatal(err)
}
key := "deadbeef"
writeVariant := func(o imageResizeOptions) string {
path := proxy.resizeCachePath(key, o)
if err := os.WriteFile(path, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
return path
}
// 更小的档位不能用于派生(会放大),更大的档位里要挑最小的那个。
writeVariant(imageResizeOptions{MaxWidth: 96, Quality: 60})
large := writeVariant(imageResizeOptions{MaxWidth: 1920, MaxHeight: 1080, Quality: 80})
card := writeVariant(imageResizeOptions{MaxWidth: 480, MaxHeight: 600, Quality: 80})
got := proxy.largerCachedVariant(key, imageResizeOptions{MaxWidth: 160, Quality: 60})
if got != card {
t.Fatalf("largerCachedVariant = %q, want the card variant %q", got, card)
}
// 目标尺寸已与大档位相同(甚至更大)时没有可用来源。
if got := proxy.largerCachedVariant(key, imageResizeOptions{MaxWidth: 1920, MaxHeight: 1080, Quality: 80}); got != "" {
t.Fatalf("expected no derivable source when the target equals the only covering variant, got %q", got)
}
if got := proxy.largerCachedVariant(key, imageResizeOptions{MaxWidth: 2400, Quality: 80}); got != "" {
t.Fatalf("expected no derivable source for oversize targets, got %q", got)
}
if large == "" {
t.Fatal("large variant should have been written")
}
}
// TestServeResizedDerivesSmallVariantFromCachedCard 小尺寸档位必须能从已缓存
// 的大尺寸档位派生,而不是每次都重新解码原图。
//
// 验证方式:生成卡片档位后,把原图内容替换成同长度、同 mtime 的非图片数据
// (缓存键不变,但原图已无法解码)。若小图请求仍能返回正确的缩略图,就说明它
// 是从缓存档位派生的;否则只能退回原图直出(此处会失败,因为原图已不是图片)。
func TestServeResizedDerivesSmallVariantFromCachedCard(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "poster.png")
original := encodeTestPNG(t, 1200, 1800, 255)
if err := os.WriteFile(source, original, 0o644); err != nil {
t.Fatal(err)
}
// 固定 mtime 并读回平台量化后的值,保证替换内容后缓存键不变。
fixed := time.Now().Add(-time.Hour).Truncate(time.Second)
if err := os.Chtimes(source, fixed, fixed); err != nil {
t.Fatal(err)
}
srcStat, err := os.Stat(source)
if err != nil {
t.Fatal(err)
}
proxy := &ImageProxy{cacheDir: filepath.Join(dir, "cache"), log: zap.NewNop()}
card := imageResizeOptions{MaxWidth: 480, MaxHeight: 600, Quality: 80}
cardRec := httptest.NewRecorder()
if !proxy.serveResizedFromFile(cardRec, httptest.NewRequest("GET", "/x?maxWidth=480", nil), source, card) {
t.Fatal("expected the card variant request to be handled")
}
cardCfg, _, err := image.DecodeConfig(bytes.NewReader(cardRec.Body.Bytes()))
if err != nil {
t.Fatalf("decode card variant: %v", err)
}
if cardCfg.Width != 400 || cardCfg.Height != 600 {
t.Fatalf("card variant = %dx%d, want 400x600", cardCfg.Width, cardCfg.Height)
}
// 把原图换成同样长度的非图片数据,并恢复完全相同的 mtime,保持缓存键不变。
garbage := bytes.Repeat([]byte{0x11}, len(original))
if err := os.WriteFile(source, garbage, 0o644); err != nil {
t.Fatal(err)
}
if err := os.Chtimes(source, srcStat.ModTime(), srcStat.ModTime()); err != nil {
t.Fatal(err)
}
tiny := imageResizeOptions{MaxWidth: 160, Quality: 60}
tinyRec := httptest.NewRecorder()
if !proxy.serveResizedFromFile(tinyRec, httptest.NewRequest("GET", "/x?maxWidth=160", nil), source, tiny) {
t.Fatal("expected the tiny variant request to be handled")
}
tinyCfg, _, err := image.DecodeConfig(bytes.NewReader(tinyRec.Body.Bytes()))
if err != nil {
t.Fatalf("decode tiny variant: %v (the request fell back to the placeholder instead of deriving from the cached card)", err)
}
if tinyCfg.Width != 160 || tinyCfg.Height != 240 {
t.Fatalf("tiny variant = %dx%d, want 160x240", tinyCfg.Width, tinyCfg.Height)
}
}
// TestPrefetchCardVariantWarmsResizeCache 刮削预取要顺带生成卡片档位,
// 首个海报墙请求即可命中缩放缓存。
func TestPrefetchCardVariantWarmsResizeCache(t *testing.T) {
payload := encodeTestPNG(t, 1200, 1800, 255)
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write(payload)
}))
defer upstream.Close()
proxy := newStreamImageProxy(t, upstream.URL)
raw := upstream.URL + "/poster.png"
if err := proxy.PrefetchCardVariant(t.Context(), raw); err != nil {
t.Fatalf("PrefetchCardVariant failed: %v", err)
}
_, cachePath, _ := proxy.remoteImageCachePathsForValidated(raw)
stat, err := os.Stat(cachePath)
if err != nil {
t.Fatalf("expected the original to be cached: %v", err)
}
variantPath := proxy.resizeCachePath(prefetchCardResizeOptions.resizeSourceKey(cachePath, stat), prefetchCardResizeOptions)
if _, err := os.Stat(variantPath); err != nil {
t.Fatalf("expected the card variant to be pre-generated at %s: %v", variantPath, err)
}
// 预取失败时仍要返回错误(刮削据此保留旧图)。
bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadGateway)
}))
defer bad.Close()
failing := newStreamImageProxy(t, bad.URL)
if err := failing.PrefetchCardVariant(t.Context(), bad.URL+"/poster.png"); err == nil {
t.Fatal("expected an error for an unreachable candidate image")
}
}
// TestPrefetchCardVariantSurvivesUnresizableImage 上游返回的图无法缩放时,
// 预取仍视为成功(图片可达即可),不能因此让刮削回退到旧图。
func TestPrefetchCardVariantSurvivesUnresizableImage(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/jpeg")
_, _ = w.Write(testJPEG)
}))
defer upstream.Close()
proxy := newStreamImageProxy(t, upstream.URL)
if err := proxy.PrefetchCardVariant(t.Context(), upstream.URL+"/cover.jpg"); err != nil {
t.Fatalf("PrefetchCardVariant failed: %v", err)
}
}
+2 -2
View File
@@ -199,7 +199,7 @@ func TestManualSearchFallsBackToMovieFolderForGenericQuery(t *testing.T) {
http.NotFound(w, r)
return
}
if r.URL.Query().Get("query") != "inception" {
if !strings.EqualFold(r.URL.Query().Get("query"), "inception") {
_ = json.NewEncoder(w).Encode(map[string]any{"results": []any{}})
return
}
@@ -248,7 +248,7 @@ func TestManualSearchFallsBackToMovieFolderForGenericQuery(t *testing.T) {
if len(results) != 1 || results[0].TMDbID != 27205 {
t.Fatalf("manual search results=%#v, want folder fallback candidate; queries=%v", results, queries)
}
if len(queries) < 2 || queries[0] != "00000" || queries[len(queries)-1] != "inception" {
if len(queries) < 2 || queries[0] != "00000" || !strings.EqualFold(queries[len(queries)-1], "inception") {
t.Fatalf("manual search queries=%v, want explicit query then folder fallback", queries)
}
}
+12 -3
View File
@@ -182,10 +182,13 @@ func (s *MediaService) listVersionSiblings(ctx context.Context, media *model.Med
// 没有 douban_id/thetvdb_id(MetaTube 刮削出来的那几个才有)。若仍按外部
// ID 预先收窄候选集,同番号的其它分片会被 SQL 直接排除,表现就是库里折叠
// 出了 N 个版本、详情页却只列出带 ID 的那几个。因此成人条目不预先收窄,
// 直接在该库范围内比对版本键。
// 直接在该库范围内比对版本键。未刮削但文件名带番号的分片同理:同一部片的
// 各分片元数据来源不一致(刮削 vs pending),收窄后标题也对不上。
var candidates []model.Media
narrowed := false
if mediaAdultGroupCode(*media) == "" {
hasAdultCode := mediaAdultGroupCode(*media) != "" ||
canonicalAdultGroupCode(AdultCodeFromMediaPath(media.Path)) != ""
if !hasAdultCode {
candidates, narrowed, err = s.repo.Media.ListVersionCandidates(ctx, libraryIDs, *media, 5000)
if err != nil {
return nil, err
@@ -198,9 +201,15 @@ func (s *MediaService) listVersionSiblings(ctx context.Context, media *model.Med
}
}
s.attachLibraryMetadata(ctx, candidates)
// 同番号只要有一个分片被确认为成人内容,尚未刮削的分片也按番号折叠,
// 与列表页的分组结果保持一致。
vouched := adultCodesVouchedByNSFW(candidates)
if vouchKey := adultVouchedGroupKey(*media, vouched); vouchKey != "" {
key = vouchKey
}
matched := make([]model.Media, 0, 4)
for _, row := range candidates {
if mediaVersionGroupKey(row) == key {
if mediaVersionGroupKeyWithAdultVouch(row, vouched) == key {
matched = append(matched, row)
}
}
+60 -7
View File
@@ -77,6 +77,10 @@ func groupMediaVersions(items []model.Media) []MediaItem {
if len(items) == 0 {
return nil
}
// 同一番号只要有一个分片被刮削标记为成人内容,就认定整部片都是成人条目:
// 未刮削的分片(nsfw=false)也按番号折叠。否则「一个分片刮削成功、其余仍
// pending」时,已刮削的走番号分组、其余落到标题分组,整部片被拆成两张卡。
adultCodes := adultCodesVouchedByNSFW(items)
type group struct {
key string
rows []model.Media
@@ -84,7 +88,7 @@ func groupMediaVersions(items []model.Media) []MediaItem {
groups := make([]group, 0, len(items))
byKey := make(map[string]int, len(items))
for _, item := range items {
key := mediaVersionGroupKey(item)
key := mediaVersionGroupKeyWithAdultVouch(item, adultCodes)
if key == "" {
groups = append(groups, group{rows: []model.Media{item}})
continue
@@ -135,16 +139,65 @@ func GroupEpisodeVersionsForDisplay(items []model.Media) []MediaItem {
return grouped
}
func mediaVersionGroupKey(m model.Media) string {
// 远程 Emby 挂载条目保持独立,不与其它远程条目或本地条目折叠合并。
if IsEmbyRemoteID(m.ID) {
return fmt.Sprintf("embyremote:%s", m.ID)
}
// mediaVersionLibraryKey 返回版本身份里使用的库标识。
func mediaVersionLibraryKey(m model.Media) string {
libKey := strings.ToLower(strings.TrimSpace(m.LibraryID))
if libKey == "" {
libKey = strings.ToLower(strings.TrimSpace(m.DisplayLibraryID))
}
return libKey
}
// adultCodesVouchedByNSFW 收集「已被刮削确认为成人内容」的番号。
func adultCodesVouchedByNSFW(items []model.Media) map[string]bool {
codes := map[string]bool{}
for _, item := range items {
if !item.NSFW {
continue
}
if code := canonicalAdultGroupCode(AdultCodeFromMediaPath(item.Path)); code != "" {
codes[code] = true
continue
}
if code := mediaAdultGroupCode(item); code != "" {
codes[code] = true
}
}
return codes
}
// mediaVersionGroupKeyWithAdultVouch 是版本身份的统一入口:番号已被同组分片
// 确认时优先按番号折叠,否则沿用 mediaVersionGroupKey 的原判定。
func mediaVersionGroupKeyWithAdultVouch(m model.Media, vouched map[string]bool) string {
if key := adultVouchedGroupKey(m, vouched); key != "" {
return key
}
return mediaVersionGroupKey(m)
}
// adultVouchedGroupKey 在番号已被同组分片确认时,返回番号分组键;
// 其余情况返回空串,表示沿用 mediaVersionGroupKey 的原判定。
func adultVouchedGroupKey(m model.Media, vouched map[string]bool) string {
if len(vouched) == 0 || m.NSFW {
return ""
}
code := canonicalAdultGroupCode(AdultCodeFromMediaPath(m.Path))
if code == "" || !vouched[code] {
return ""
}
libKey := mediaVersionLibraryKey(m)
if libKey == "" {
libKey = "_"
}
return fmt.Sprintf("adult:%s:%s", libKey, code)
}
func mediaVersionGroupKey(m model.Media) string { // 远程 Emby 挂载条目保持独立,不与其它远程条目或本地条目折叠合并。
if IsEmbyRemoteID(m.ID) {
return fmt.Sprintf("embyremote:%s", m.ID)
}
libKey := mediaVersionLibraryKey(m)
specialKind := mediaSpecialKind(m.Path)
season, episode := m.SeasonNum, m.EpisodeNum
if specialKind != "" && specialKind != mediaSpecialTheatrical && episode <= 0 {
@@ -0,0 +1,66 @@
package service
import (
"strconv"
"testing"
"github.com/truewhile/MeBox/internal/model"
)
// 真实场景:同一部片的分片里只有第 4 个被刮削成功(nsfw=true),其余仍是 pending。
// 修复前:已刮削的分片走「番号」分组、其余落到「标题」分组,7 个分片会裂成两张卡
// (一张只有第 4 个,另一张是其余 6 个)。
func TestGroupMediaVersionsKeepsUnscrapedPartsWithScrapedSibling(t *testing.T) {
const libID = "lib-1"
const dir = "/media/云下载/sivr-270/"
parts := func(nums ...int) []model.Media {
out := make([]model.Media, 0, len(nums))
for _, n := range nums {
out = append(out, model.Media{
LibraryID: libID,
Title: "sivr 270",
Path: dir + "sivr-270-" + strconv.Itoa(n) + ".strm",
Container: "strm",
})
}
return out
}
items := parts(1, 2, 3)
items = append(items, model.Media{
LibraryID: libID,
Title: "SIVR-270-【VR】河北彩花×ご奉仕ナース",
OriginalName: "SIVR-270",
Path: dir + "sivr-270-4.strm",
NSFW: true,
ScrapeStatus: "matched",
Container: "strm",
})
items = append(items, parts(5, 6, 7)...)
grouped := GroupMediaVersions(items)
if len(grouped) != 1 {
paths := make([]string, 0, len(grouped))
for _, g := range grouped {
paths = append(paths, g.Path)
}
t.Fatalf("分成 %d 张卡 %v,期望折叠成 1 张", len(grouped), paths)
}
if len(grouped[0].Versions) != 7 {
t.Fatalf("版本数 = %d,期望 7", len(grouped[0].Versions))
}
}
// 反向保护:没有任何分片被确认为成人内容时,不能仅凭文件名里的番号形态折叠,
// 否则普通媒体也可能被误并进同一张卡。
func TestGroupMediaVersionsIgnoresUnvouchedAdultCode(t *testing.T) {
items := []model.Media{
{LibraryID: "lib-2", Title: "Alpha Movie", Path: "/media/movies/Alpha Movie/ABC-123-1080p.mkv"},
{LibraryID: "lib-2", Title: "Beta Movie", Path: "/media/movies/Beta Movie/ABC-123-2160p.mkv"},
}
grouped := GroupMediaVersions(items)
if len(grouped) != 2 {
t.Fatalf("分成 %d 张卡,期望 2 张(未被确认成人内容的番号不参与折叠)", len(grouped))
}
}
+41 -1
View File
@@ -76,7 +76,10 @@ type episodeNFO struct {
}
// ExportOne writes a movie.nfo file next to the media file. Existing files
// are overwritten so a re-scrape always reflects the latest metadata.
// are overwritten so a re-scrape always reflects the latest metadata. When the
// folder already holds a sidecar the reader would pick (movie.nfo /
// <dirname>.nfo), that file is updated in place instead of adding a second,
// divergent NFO.
func (s *NFOService) ExportOne(ctx context.Context, mediaID string) (string, error) {
m, err := s.repo.Media.FindByID(ctx, mediaID)
if err != nil {
@@ -191,12 +194,49 @@ func WriteMediaNFO(m *model.Media) (string, error) {
return "", err
}
dst := nfoPath(resolveMappedDestinationPath(m.Path))
if m.SeasonNum <= 0 && m.EpisodeNum <= 0 {
dst = nfoExportTarget(dst)
}
if err := os.WriteFile(dst, []byte(xml.Header+string(out)+"\n"), 0o644); err != nil { // #nosec G306 -- NFO sidecars must remain readable by media players.
return "", err
}
return dst, nil
}
// nfoExportTarget picks the file an export should write for a movie.
//
// Besides the canonical "<base>.nfo", the reader (findMovieNFO) also accepts
// movie.nfo and "<dirname>.nfo". Writing the canonical name into a folder that
// already carries one of those left two divergent NFOs for the same movie, and
// because the reader prefers the canonical one the pre-existing file quietly went
// stale. Reuse whichever file the reader would already have picked, in the same
// precedence order, so an export updates the existing sidecar in place.
//
// Episodes are deliberately excluded by the caller: for an episode the
// alternatives are series-level files, and writing episode metadata there would
// corrupt the series identity.
func nfoExportTarget(canonical string) string {
dir := filepath.Dir(canonical)
candidates := []string{
filepath.Base(canonical),
"movie.nfo",
filepath.Base(dir) + ".nfo",
}
for _, name := range candidates {
if name == "" || name == "." {
continue
}
candidate := filepath.Join(dir, name)
if candidate == canonical {
continue
}
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
return candidate
}
}
return canonical
}
func splitNFOList(value string) []string {
parts := strings.Split(value, ",")
out := make([]string, 0, len(parts))
@@ -0,0 +1,145 @@
package service
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/truewhile/MeBox/internal/model"
)
func writeNFOTestFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
// A folder that already carries the Kodi/Emby convention "movie.nfo" must be
// updated in place. Exporting used to always write "<filename>.nfo", leaving two
// divergent NFOs for one movie — and since the reader prefers "<filename>.nfo",
// the pre-existing movie.nfo silently went stale.
func TestWriteMediaNFOUpdatesExistingMovieNFO(t *testing.T) {
dir := t.TempDir()
mediaPath := filepath.Join(dir, "The Shawshank Redemption (1994).mp4")
writeNFOTestFile(t, mediaPath, "video")
existing := filepath.Join(dir, "movie.nfo")
writeNFOTestFile(t, existing, `<?xml version="1.0"?><movie><title>旧标题</title></movie>`)
media := &model.Media{
Title: "肖申克的救赎",
Year: 1994,
Path: mediaPath,
Overview: "希望让人自由。",
TMDbID: 278,
ScrapeStatus: "matched",
}
dst, err := WriteMediaNFO(media)
if err != nil {
t.Fatalf("WriteMediaNFO: %v", err)
}
if filepath.Clean(dst) != filepath.Clean(existing) {
t.Fatalf("export wrote %q, want the existing movie.nfo at %q", dst, existing)
}
if _, err := os.Stat(filepath.Join(dir, "The Shawshank Redemption (1994).nfo")); err == nil {
t.Fatal("export created a duplicate <filename>.nfo next to movie.nfo")
}
body, err := os.ReadFile(existing)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(body), "旧标题") || !strings.Contains(string(body), "肖申克的救赎") {
t.Fatalf("movie.nfo was not refreshed: %s", string(body))
}
}
// With no pre-existing sidecar the canonical <filename>.nfo is still the target.
func TestWriteMediaNFOWritesCanonicalNameByDefault(t *testing.T) {
dir := t.TempDir()
mediaPath := filepath.Join(dir, "Inception (2010).mkv")
writeNFOTestFile(t, mediaPath, "video")
dst, err := WriteMediaNFO(&model.Media{Title: "盗梦空间", Year: 2010, Path: mediaPath})
if err != nil {
t.Fatalf("WriteMediaNFO: %v", err)
}
want := filepath.Join(dir, "Inception (2010).nfo")
if filepath.Clean(dst) != filepath.Clean(want) {
t.Fatalf("export wrote %q, want %q", dst, want)
}
}
// Re-exporting over an existing canonical sidecar must keep updating that file.
func TestWriteMediaNFOOverwritesCanonicalSidecar(t *testing.T) {
dir := t.TempDir()
mediaPath := filepath.Join(dir, "Inception (2010).mkv")
writeNFOTestFile(t, mediaPath, "video")
canonical := filepath.Join(dir, "Inception (2010).nfo")
writeNFOTestFile(t, canonical, `<?xml version="1.0"?><movie><title>old</title></movie>`)
dst, err := WriteMediaNFO(&model.Media{Title: "盗梦空间", Year: 2010, Path: mediaPath})
if err != nil {
t.Fatalf("WriteMediaNFO: %v", err)
}
if filepath.Clean(dst) != filepath.Clean(canonical) {
t.Fatalf("export wrote %q, want %q", dst, canonical)
}
body, _ := os.ReadFile(canonical)
if strings.Contains(string(body), ">old<") {
t.Fatal("existing canonical sidecar was not refreshed")
}
}
// Episode exports must never be redirected to the series-level movie.nfo /
// <dirname>.nfo files: those describe the show, not the episode.
func TestWriteMediaNFOEpisodeIgnoresSeriesLevelSidecars(t *testing.T) {
dir := t.TempDir()
mediaPath := filepath.Join(dir, "Show - S01E01.mkv")
writeNFOTestFile(t, mediaPath, "video")
seriesNFO := filepath.Join(dir, "movie.nfo")
writeNFOTestFile(t, seriesNFO, `<?xml version="1.0"?><movie><title>series level</title></movie>`)
dst, err := WriteMediaNFO(&model.Media{
Title: "Show",
Path: mediaPath,
SeasonNum: 1,
EpisodeNum: 1,
})
if err != nil {
t.Fatalf("WriteMediaNFO: %v", err)
}
if filepath.Clean(dst) == filepath.Clean(seriesNFO) {
t.Fatal("episode export must not overwrite the series-level movie.nfo")
}
if filepath.Clean(dst) != filepath.Clean(filepath.Join(dir, "Show - S01E01.nfo")) {
t.Fatalf("episode export wrote %q, want the episode sidecar", dst)
}
body, _ := os.ReadFile(seriesNFO)
if !strings.Contains(string(body), "series level") {
t.Fatal("series-level movie.nfo was modified by an episode export")
}
}
// The <dirname>.nfo convention must be reused too.
func TestWriteMediaNFOUpdatesDirectoryNamedNFO(t *testing.T) {
parent := t.TempDir()
dir := filepath.Join(parent, "Interstellar (2014)")
mediaPath := filepath.Join(dir, "Interstellar (2014).mkv")
writeNFOTestFile(t, mediaPath, "video")
dirNFO := filepath.Join(dir, "Interstellar (2014).nfo")
// The directory-named sidecar is the only pre-existing candidate besides the
// canonical name, so the canonical name is skipped and this one is reused.
writeNFOTestFile(t, dirNFO, `<?xml version="1.0"?><movie><title>old</title></movie>`)
dst, err := WriteMediaNFO(&model.Media{Title: "星际穿越", Year: 2014, Path: mediaPath})
if err != nil {
t.Fatalf("WriteMediaNFO: %v", err)
}
if filepath.Clean(dst) != filepath.Clean(dirNFO) {
t.Fatalf("export wrote %q, want %q", dst, dirNFO)
}
}
@@ -6,6 +6,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"go.uber.org/zap"
@@ -24,7 +25,10 @@ func TestOrganizeDirectoryCleansReleaseNoiseBeforeMetadataClassify(t *testing.T)
}
query := r.URL.Query().Get("query")
queries = append(queries, query)
if query != "motherhood of taihang" {
// Real providers match the query case-insensitively; keep this stub
// faithful so the test guards release-noise cleaning rather than the
// historical lowercased query spelling.
if !strings.EqualFold(query, "motherhood of taihang") {
_ = json.NewEncoder(w).Encode(map[string]any{"results": []any{}})
return
}
@@ -81,7 +85,7 @@ func TestOrganizeDirectoryCleansReleaseNoiseBeforeMetadataClassify(t *testing.T)
if _, err := os.Stat(want); err != nil {
t.Fatalf("organized media missing at %q: %v; items=%#v queries=%v", want, err, res.Items, queries)
}
if len(queries) == 0 || queries[0] != "motherhood of taihang" {
if len(queries) == 0 || !strings.EqualFold(queries[0], "motherhood of taihang") {
t.Fatalf("first metadata query = %q, want cleaned title; all queries=%v", firstQuery(queries), queries)
}
}
@@ -74,7 +74,9 @@ func TestOrganizeDirectoryUsesAdultMetadataBeforeRename(t *testing.T) {
func TestOrganizeDirectoryUsesBangumiForAnimeRename(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/search/subject/frieren" {
// Bangumi takes the keyword in the path; compare case-insensitively so the
// stub does not pin the query spelling (titles now keep their original case).
if !strings.EqualFold(r.URL.Path, "/search/subject/frieren") {
http.NotFound(w, r)
return
}
+17 -5
View File
@@ -211,13 +211,25 @@ func DefaultCloud115Quality(options []PlaybackQuality) string {
}
// LocalQualityOptions 返回 MeBox 本地 HLS 可用的画质档位。
func LocalQualityOptions(m *model.Media) []PlaybackQuality {
//
// transcodeAvailable 为 false(未装 ffmpeg、或转码被全局关闭)时档位仍然返回,
// 但 Available 为 false:播放器据此直接走直连播放,而不是先请求 /api/hls 拿到
// 一个必然失败的 500 再回退。档位列表保留是为了让前端仍能展示"需要转码"的说明。
func LocalQualityOptions(m *model.Media, transcodeAvailable bool) []PlaybackQuality {
sourceHeight := mediaSourceHeight(m)
presets := []PlaybackQuality{
{ID: "source", Label: "原画", Height: sourceHeight, Source: "local", Available: true, Note: "本地 HLS,保持源分辨率"},
{ID: "1080", Label: "1080P", Height: 1080, Source: "local", Available: true},
{ID: "720", Label: "720P", Height: 720, Source: "local", Available: true},
{ID: "480", Label: "480P", Height: 480, Source: "local", Available: true},
{ID: "source", Label: "原画", Height: sourceHeight, Source: "local", Available: transcodeAvailable, Note: "本地 HLS,保持源分辨率"},
{ID: "1080", Label: "1080P", Height: 1080, Source: "local", Available: transcodeAvailable},
{ID: "720", Label: "720P", Height: 720, Source: "local", Available: transcodeAvailable},
{ID: "480", Label: "480P", Height: 480, Source: "local", Available: transcodeAvailable},
}
if !transcodeAvailable {
for i := range presets {
presets[i].RequiresTranscode = true
if presets[i].Note == "" {
presets[i].Note = "需要 ffmpeg,当前不可用"
}
}
}
out := make([]PlaybackQuality, 0, len(presets))
for _, preset := range presets {
+27 -2
View File
@@ -61,20 +61,45 @@ func TestCloud115QualityOptionsListsAvailableOnlyOnce(t *testing.T) {
func TestLocalQualityOptionsFollowSourceHeight(t *testing.T) {
media := &model.Media{Height: 720}
options := LocalQualityOptions(media)
options := LocalQualityOptions(media, true)
if _, ok := findPlaybackQuality(options, "1080"); ok {
t.Fatal("720p source should not list 1080P")
}
for _, id := range []string{"source", "720", "480"} {
if _, ok := findPlaybackQuality(options, id); !ok {
quality, ok := findPlaybackQuality(options, id)
if !ok {
t.Fatalf("local quality %s missing", id)
}
if !quality.Available {
t.Fatalf("local quality %s should be available while transcoding works", id)
}
}
if got := DefaultLocalHLSQualityID(media); got != "720" {
t.Fatalf("default local quality = %q, want 720", got)
}
}
// Without a usable ffmpeg the local HLS renditions must not advertise themselves
// as available: the player would request /api/hls/... and take a guaranteed 500
// before falling back to direct play.
func TestLocalQualityOptionsMarkUnavailableWithoutTranscoder(t *testing.T) {
media := &model.Media{Height: 1080}
for _, id := range []string{"source", "1080", "720", "480"} {
for _, available := range []bool{true, false} {
quality, ok := findPlaybackQuality(LocalQualityOptions(media, available), id)
if !ok {
t.Fatalf("local quality %s missing", id)
}
if quality.Available != available {
t.Fatalf("quality %s availability = %v, want %v", id, quality.Available, available)
}
if !available && !quality.RequiresTranscode {
t.Fatalf("unavailable quality %s should be flagged as requiring transcoding", id)
}
}
}
}
func TestLocalHLSQualityFFmpegArgs(t *testing.T) {
cfg := &config.Config{}
cfg.Transcoder.MaxHeight = 720
+2 -2
View File
@@ -20,8 +20,8 @@ One\.Piece\.S01E(89[2-9]|9\d{2}|10\d{2})\.1999 => 海贼王.S21E\1.1999 && S21E
func TestCleanQueryWithRecognitionDisabledByDefaultRepoNil(t *testing.T) {
title, year := CleanQueryWithRecognition(t.Context(), nil, "Dune.2021.2160p.WEB-DL.mkv")
if title != "dune" || year != 2021 {
t.Fatalf("CleanQueryWithRecognition = %q/%d, want dune/2021", title, year)
if title != "Dune" || year != 2021 {
t.Fatalf("CleanQueryWithRecognition = %q/%d, want Dune/2021", title, year)
}
}
+36 -4
View File
@@ -36,6 +36,16 @@ type RuntimeCacheService struct {
limit int
maxBytes int64
bytesUsed int64
// seq stamps every insert/access with a strictly increasing number. Two
// entries written or touched inside the same clock tick carry an identical
// lastUsed, and Go map iteration order is random, so eviction used to pick an
// arbitrary "oldest" entry. seq turns recency into a total order.
seq uint64
}
func (c *RuntimeCacheService) nextSeqLocked() uint64 {
c.seq++
return c.seq
}
type runtimeCacheItem struct {
@@ -43,6 +53,7 @@ type runtimeCacheItem struct {
expiresAt time.Time
staleUntil time.Time
lastUsed time.Time
seq uint64
size int64
}
@@ -55,6 +66,7 @@ type runtimeObjectItem struct {
value any
expiresAt time.Time
lastUsed time.Time
seq uint64
size int64
}
@@ -225,6 +237,7 @@ func (c *RuntimeCacheService) GetObject(key string) (any, bool) {
return nil, false
}
item.lastUsed = now
item.seq = c.nextSeqLocked()
c.obj[fullKey] = item
c.mu.Unlock()
return item.value, true
@@ -252,6 +265,7 @@ func (c *RuntimeCacheService) SetObject(key string, value any, ttl time.Duration
value: value,
expiresAt: now.Add(ttl),
lastUsed: now,
seq: c.nextSeqLocked(),
size: size,
}
c.bytesUsed += size
@@ -334,6 +348,7 @@ func (c *RuntimeCacheService) getMemoryWithStale(key string) ([]byte, bool, bool
return nil, false, false
}
item.lastUsed = now
item.seq = c.nextSeqLocked()
c.memory[key] = item
return item.raw, true, !now.Before(item.expiresAt)
}
@@ -376,6 +391,7 @@ func (c *RuntimeCacheService) setMemoryBytesWithStale(key string, raw []byte, fr
expiresAt: now.Add(freshTTL),
staleUntil: now.Add(staleTTL),
lastUsed: now,
seq: c.nextSeqLocked(),
size: size,
}
c.bytesUsed += size
@@ -424,20 +440,36 @@ func (c *RuntimeCacheService) evictExpiredLocked(now time.Time) {
}
}
// evictOldestLocked removes the least recently used entry across both caches.
//
// Recency is ordered by (lastUsed, seq): entries written within the same clock
// tick share lastUsed, and map iteration order is randomized, so comparing
// timestamps alone let eviction drop a just-written entry instead of the older
// one. seq breaks those ties deterministically.
func (c *RuntimeCacheService) evictOldestLocked() bool {
var (
oldestKey string
oldestKind byte
oldestAt time.Time
oldestSeq uint64
)
older := func(at time.Time, seq uint64) bool {
if oldestKind == 0 {
return true
}
if at.Equal(oldestAt) {
return seq < oldestSeq
}
return at.Before(oldestAt)
}
for key, item := range c.memory {
if oldestKind == 0 || item.lastUsed.Before(oldestAt) {
oldestKey, oldestKind, oldestAt = key, 'm', item.lastUsed
if older(item.lastUsed, item.seq) {
oldestKey, oldestKind, oldestAt, oldestSeq = key, 'm', item.lastUsed, item.seq
}
}
for key, item := range c.obj {
if oldestKind == 0 || item.lastUsed.Before(oldestAt) {
oldestKey, oldestKind, oldestAt = key, 'o', item.lastUsed
if older(item.lastUsed, item.seq) {
oldestKey, oldestKind, oldestAt, oldestSeq = key, 'o', item.lastUsed, item.seq
}
}
switch oldestKind {
@@ -0,0 +1,108 @@
package service
import (
"context"
"strings"
"testing"
"time"
)
// Eviction must be deterministic when entries share a lastUsed timestamp.
//
// Regression: recency was compared by lastUsed alone. Entries written within the
// same clock tick carry identical timestamps and Go map iteration order is
// randomized, so eviction could drop a just-written entry instead of the oldest
// one — which made TestRuntimeCacheSetMaxSizeEvictsImmediately fail intermittently
// in a full-package run.
func TestRuntimeCacheEvictionBreaksTimestampTiesDeterministically(t *testing.T) {
// Repeat because the bug only showed up for some map iteration orders.
for attempt := 0; attempt < 50; attempt++ {
cache := newRuntimeCacheForTest(t, 128)
const perEntry = 700 << 10
// Two entries large enough that a 1MiB budget can hold only one. Writing
// them back to back puts them in the same clock tick often enough to hit
// the tie almost immediately.
cache.SetObject("old", strings.Repeat("a", perEntry), time.Minute)
cache.SetObject("new", strings.Repeat("b", perEntry), time.Minute)
cache.SetMaxSizeMB(1)
if _, ok := cache.GetObject("old"); ok {
t.Fatalf("attempt %d: oldest entry survived the budget drop", attempt)
}
if _, ok := cache.GetObject("new"); !ok {
t.Fatalf("attempt %d: newest entry was evicted instead of the oldest", attempt)
}
}
}
// Accessing an entry refreshes its recency even when it lands in the same tick as
// the write of a competing entry.
func TestRuntimeCacheAccessRefreshesEvictionOrder(t *testing.T) {
cache := newRuntimeCacheForTest(t, 128)
const perEntry = 700 << 10
cache.SetObject("first", strings.Repeat("a", perEntry), time.Minute)
cache.SetObject("second", strings.Repeat("b", perEntry), time.Minute)
// Touch the older entry so it becomes the more recent one.
if _, ok := cache.GetObject("first"); !ok {
t.Fatal("first entry should be present before the budget drop")
}
cache.SetMaxSizeMB(1)
if _, ok := cache.GetObject("second"); ok {
t.Fatal("the untouched entry should have been evicted first")
}
if _, ok := cache.GetObject("first"); !ok {
t.Fatal("the just-accessed entry should have been kept")
}
}
// Eviction picks the oldest across the byte cache and the object cache together.
func TestRuntimeCacheEvictionComparesBothCaches(t *testing.T) {
cache := newRuntimeCacheForTest(t, 128)
const perEntry = 700 << 10
// Byte-cache entry written first, object-cache entry second.
payload := strings.Repeat("c", perEntry)
cache.SetJSON(context.Background(), "bytes", payload, time.Minute)
cache.SetObject("object", strings.Repeat("d", perEntry), time.Minute)
cache.SetMaxSizeMB(1)
if _, ok := cache.GetObject("object"); !ok {
t.Fatal("the newer object entry must be kept")
}
var decoded string
if cache.GetJSON(context.Background(), "bytes", &decoded) {
t.Fatal("the older byte-cache entry should have been evicted")
}
}
// The sequence counter must hand out strictly increasing values, otherwise ties
// would resolve randomly again.
func TestRuntimeCacheSequenceIsMonotonic(t *testing.T) {
cache := newRuntimeCacheForTest(t, 16)
seen := map[uint64]bool{}
cache.mu.Lock()
var previous uint64
for i := 0; i < 100; i++ {
seq := cache.nextSeqLocked()
if seen[seq] {
cache.mu.Unlock()
t.Fatalf("sequence %d handed out twice", seq)
}
if seq <= previous && previous != 0 {
cache.mu.Unlock()
t.Fatalf("sequence went backwards: %d after %d", seq, previous)
}
seen[seq] = true
previous = seq
}
cache.mu.Unlock()
if len(seen) != 100 {
t.Fatalf("expected 100 distinct sequence numbers, got %d", len(seen))
}
}
+14
View File
@@ -119,6 +119,20 @@ func ApplyRuntimeSetting(cfg *config.Config, key, value string) {
}
cfg.Cache.ImagesMaxSizeMB = n
}
case "cache.images_originals_max_size_mb":
if n, err := strconv.Atoi(value); err == nil {
if n < 0 {
n = 0
}
cfg.Cache.ImagesOriginalsMaxSizeMB = n
}
case "cache.images_originals_ttl_hours":
if n, err := strconv.Atoi(value); err == nil {
if n < 0 {
n = 0
}
cfg.Cache.ImagesOriginalsTTLHours = n
}
case "cache.memory_max_size_mb":
n := config.DefaultCacheMemoryMaxSizeMB
if value != "" {
+79
View File
@@ -0,0 +1,79 @@
package service
import (
"os"
"path/filepath"
"testing"
"go.uber.org/zap"
)
// Dot-prefixed directory names are legal and common (hidden media folders,
// ".staging" drop dirs). cleanEntryName used to trim dots from BOTH ends, which
// silently rewrote `.media` into `media`.
func TestCleanEntryNameKeepsLeadingDots(t *testing.T) {
cases := []struct {
name string
isDir bool
want string
}{
{name: ".media", isDir: true, want: ".media"},
{name: ".tmp-live-media", isDir: true, want: ".tmp-live-media"},
{name: ".staging", isDir: true, want: ".staging"},
{name: "Season 01.", isDir: true, want: "Season 01"},
{name: " trailing space ", isDir: true, want: "trailing space"},
{name: ".hidden.mkv", isDir: false, want: ".hidden.mkv"},
{name: "poster.jpg", isDir: false, want: "poster.jpg"},
{name: "file:name?test*<foo>|bar\".mkv", isDir: false, want: "file nametestfoobar.mkv"},
// Names that are nothing but dots must still collapse to a safe placeholder.
{name: ".", isDir: true, want: "unnamed"},
{name: "..", isDir: true, want: "unnamed"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := cleanEntryName(tc.name, tc.isDir); got != tc.want {
t.Fatalf("cleanEntryName(%q, %v) = %q, want %q", tc.name, tc.isDir, got, tc.want)
}
})
}
}
// sanitizeLocalPath must preserve a dot-prefixed directory in the middle of an
// absolute path. Regression: artwork sidecars for a library at
// `<root>/.tmp-live-media/电影/...` were written to `<root>/tmp-live-media/...`
// instead — a sibling tree outside the media folder.
func TestSanitizeLocalPathKeepsDotPrefixedSegments(t *testing.T) {
dir := filepath.Join(string(filepath.Separator)+"media", ".hidden-lib", "电影", "Inception (2010)")
got := sanitizeLocalPath(dir)
if got != dir {
t.Fatalf("sanitizeLocalPath(%q) = %q, want the path unchanged", dir, got)
}
}
// End-to-end guard for the observed bug: writing scraped artwork into a media
// folder located under a dot-prefixed library root must land inside that folder,
// never in a dot-stripped sibling directory.
func TestArtworkWriteKeepsDotPrefixedLibraryPath(t *testing.T) {
root := t.TempDir()
libraryRoot := filepath.Join(root, ".media")
mediaDir := filepath.Join(libraryRoot, "电影", "Inception (2010)")
if err := os.MkdirAll(mediaDir, 0o755); err != nil {
t.Fatal(err)
}
svc := &ScraperService{log: zap.NewNop()}
dst := svc.writeArtworkDataToPath(mediaDir, "Inception (2010)-poster", "image/jpeg", []byte{0xff, 0xd8, 0xff, 0xdb, 0x00})
if dst == "" {
t.Fatal("artwork write reported no destination")
}
if filepath.Dir(filepath.Clean(dst)) != filepath.Clean(mediaDir) {
t.Fatalf("artwork written to %q, want a file inside %q", dst, mediaDir)
}
if _, err := os.Stat(filepath.Join(mediaDir, "Inception (2010)-poster.jpg")); err != nil {
t.Fatalf("sidecar missing from the media folder: %v", err)
}
// A dot-stripped sibling must not be created.
if _, err := os.Stat(filepath.Join(root, "media")); err == nil {
t.Fatal("dot-stripped sibling directory was created")
}
}
+3 -3
View File
@@ -114,11 +114,11 @@ func TestScanLibraryUsesISOParentFolderForScrapeIdentity(t *testing.T) {
if err := repos.DB.First(&media).Error; err != nil {
t.Fatal(err)
}
if media.Title != "dune part two" || media.Year != 2024 || media.ScrapeStatus != "pending" {
if media.Title != "Dune Part Two" || media.Year != 2024 || media.ScrapeStatus != "pending" {
t.Fatalf("ISO scrape identity = title=%q year=%d status=%q", media.Title, media.Year, media.ScrapeStatus)
}
candidates := scrapeQueryCandidates(&media, &lib)
if len(candidates) == 0 || candidates[0] != "dune part two" {
if len(candidates) == 0 || candidates[0] != "Dune Part Two" {
t.Fatalf("ISO scrape candidates = %#v", candidates)
}
}
@@ -161,7 +161,7 @@ func TestScanLibraryRepairsPreviouslyUnmatchedGenericISO(t *testing.T) {
if err := repos.DB.First(&media, "id = ?", legacy.ID).Error; err != nil {
t.Fatal(err)
}
if media.Title != "dune part two" || media.Year != 2024 || media.ScrapeStatus != "pending" {
if media.Title != "Dune Part Two" || media.Year != 2024 || media.ScrapeStatus != "pending" {
t.Fatalf("repaired ISO = title=%q year=%d status=%q", media.Title, media.Year, media.ScrapeStatus)
}
}
+41
View File
@@ -122,11 +122,52 @@ func (s *ScannerService) localMediaScanState(in localMediaScanStateInput) (bool,
existing, exists := in.existingMedia[in.cleanPath]
isNewMedia := !exists
if exists && in.ext != ".strm" && existing.SizeBytes == in.size && !localMetadataNeedsRefresh(existing, in.localMeta) && !localDerivedMetadataNeedsRefresh(existing, in.incoming) {
// Unchanged rows are skipped, but a row that never received ffprobe data
// still needs a backfill probe so installing ffmpeg later repairs the library.
s.queueProbeBackfillIfNeeded(in.path, in.ext, existing)
return isNewMedia, true
}
return isNewMedia, false
}
// queueProbeBackfillIfNeeded re-queues ffprobe for an unchanged media row that
// still carries no technical metadata, reporting whether a probe was queued.
//
// The incremental skip above only compares file size and metadata, so a library
// scanned while ffprobe was unavailable kept duration/resolution/codec empty
// forever: installing ffmpeg and rescanning did nothing, and the only way out was
// a per-item manual "探测媒体轨". Only the probe is re-queued here — the row
// itself is not rewritten, so scraped metadata and titles stay untouched.
func (s *ScannerService) queueProbeBackfillIfNeeded(path, ext string, existing existingLocalMedia) bool {
if !mediaExtensionSupportsProbe(ext) || !localMediaProbeDataMissing(existing) {
return false
}
// Without a resolvable ffprobe/ffmpeg every queued probe would just fail, so
// only queue when probing is actually possible right now.
if s == nil || s.probe == nil || !s.probe.Available() {
return false
}
if !s.queueLocalMediaProbe(path) {
return false
}
if s.log != nil {
s.log.Debug("rescan queued ffprobe backfill for media without track metadata", zap.String("path", path))
}
return true
}
// localMediaProbeDataMissing reports whether a media row has none of the fields
// ffprobe fills in. Requiring every field to be empty keeps genuinely probed
// media (which always yields at least a codec or a duration) out of the backfill
// path.
func localMediaProbeDataMissing(existing existingLocalMedia) bool {
return existing.DurationSec <= 0 &&
existing.Width <= 0 &&
existing.Height <= 0 &&
strings.TrimSpace(existing.VideoCodec) == "" &&
strings.TrimSpace(existing.AudioCodec) == ""
}
type localScanMediaInput struct {
lib *model.Library
root *model.LibraryRoot
@@ -263,7 +263,7 @@ func TestScanLibraryRefreshesStaleNoMatchDerivedMetadata(t *testing.T) {
if err := db.First(&media, "path = ?", mediaPath).Error; err != nil {
t.Fatal(err)
}
if media.Title != "hntv spring festival gala" || media.SeasonNum != 1 || media.EpisodeNum != 202 || media.ScrapeStatus != "pending" {
if media.Title != "Hntv Spring Festival Gala" || media.SeasonNum != 1 || media.EpisodeNum != 202 || media.ScrapeStatus != "pending" {
t.Fatalf("stale no_match row was not refreshed: title=%q s=%d e=%d status=%q", media.Title, media.SeasonNum, media.EpisodeNum, media.ScrapeStatus)
}
}
@@ -0,0 +1,167 @@
package service
import (
"os"
"path/filepath"
"testing"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
// newProbeBackfillScanner builds a scanner over the given repository whose
// ffprobe "binary" is a temp file, so probe availability is deterministic
// regardless of what happens to be installed on the test machine.
func newProbeBackfillScanner(t *testing.T, repos *repository.Container, probeAvailable bool) *ScannerService {
t.Helper()
cfg := &config.Config{}
probePath := filepath.Join(t.TempDir(), "not-a-real-ffprobe")
if probeAvailable {
if err := os.WriteFile(probePath, []byte("stub"), 0o755); err != nil {
t.Fatalf("write stub probe: %v", err)
}
}
// Point the probe and its ffmpeg fallback at paths that only exist when this
// test wants them to, so Available() cannot pick up a system binary.
cfg.App.FFprobePath = probePath
cfg.App.FFmpegPath = probePath
return NewScannerService(cfg, zap.NewNop(), repos, NewHub(zap.NewNop()), NewFFprobeService(cfg, zap.NewNop()), nil)
}
func newProbeBackfillRepo(t *testing.T) *repository.Container {
t.Helper()
return repository.New(newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{}))
}
func probeBackfillScanner(t *testing.T, probeAvailable bool) *ScannerService {
t.Helper()
return newProbeBackfillScanner(t, newProbeBackfillRepo(t), probeAvailable)
}
// localMediaProbeDataMissing decides which unchanged rows are worth re-probing.
func TestLocalMediaProbeDataMissing(t *testing.T) {
cases := []struct {
name string
existing existingLocalMedia
want bool
}{
{name: "never probed", existing: existingLocalMedia{}, want: true},
{name: "duration only", existing: existingLocalMedia{DurationSec: 148}, want: false},
{name: "resolution only", existing: existingLocalMedia{Width: 1920, Height: 1080}, want: false},
{name: "video codec only", existing: existingLocalMedia{VideoCodec: "h264"}, want: false},
{name: "audio codec only", existing: existingLocalMedia{AudioCodec: "aac"}, want: false},
{name: "fully probed", existing: existingLocalMedia{DurationSec: 148, Width: 1920, Height: 1080, VideoCodec: "h264", AudioCodec: "aac"}, want: false},
{name: "blank codecs are not data", existing: existingLocalMedia{VideoCodec: " ", AudioCodec: " "}, want: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := localMediaProbeDataMissing(tc.existing); got != tc.want {
t.Fatalf("localMediaProbeDataMissing(%+v) = %v, want %v", tc.existing, got, tc.want)
}
})
}
}
// A rescan must re-queue ffprobe for media that still has no technical metadata,
// otherwise a library scanned before ffmpeg was installed stays empty forever.
func TestRescanQueuesProbeBackfillForUnprobedMedia(t *testing.T) {
sc := probeBackfillScanner(t, true)
if !sc.queueProbeBackfillIfNeeded("/media/movie.mkv", ".mkv", existingLocalMedia{}) {
t.Fatal("unprobed media should get a backfill probe queued")
}
if sc.queueProbeBackfillIfNeeded("/media/movie.mkv", ".mkv", existingLocalMedia{DurationSec: 148, VideoCodec: "h264"}) {
t.Fatal("already probed media must not be re-queued")
}
}
// Backfill is pointless without a usable binary: every probe would fail, so the
// scanner must not flood the probe queue.
func TestRescanSkipsProbeBackfillWhenProbeUnavailable(t *testing.T) {
sc := probeBackfillScanner(t, false)
if sc.probe.Available() {
t.Skip("a system ffprobe/ffmpeg is resolvable in this environment; availability gating cannot be exercised")
}
if sc.queueProbeBackfillIfNeeded("/media/movie.mkv", ".mkv", existingLocalMedia{}) {
t.Fatal("no probe should be queued while ffprobe/ffmpeg is unavailable")
}
}
// Formats that mediaExtensionSupportsProbe excludes must never enter the backfill
// path, even when the row looks unprobed.
func TestRescanSkipsProbeBackfillForUnprobeableExtensions(t *testing.T) {
sc := probeBackfillScanner(t, true)
for _, ext := range []string{".strm", ".iso"} {
if sc.queueProbeBackfillIfNeeded("/media/item"+ext, ext, existingLocalMedia{}) {
t.Fatalf("ext %s must not be queued for a probe backfill", ext)
}
}
}
// End-to-end guard: after a scan leaves a row without technical metadata, the
// incremental-skip path for an unchanged file must still schedule a backfill
// probe rather than skipping the file forever.
func TestScanLibraryQueuesBackfillOnUnchangedRescan(t *testing.T) {
repos := newProbeBackfillRepo(t)
root := t.TempDir()
lib := model.Library{Name: "Movies", Path: root, Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
file := filepath.Join(root, "Some Movie (2021).mkv")
if err := os.WriteFile(file, []byte("data"), 0o644); err != nil {
t.Fatal(err)
}
first := newProbeBackfillScanner(t, repos, true)
res, err := first.ScanLibrary(t.Context(), lib.ID)
if err != nil {
t.Fatalf("first scan: %v", err)
}
if res.Added != 1 {
t.Fatalf("first scan added=%d, want 1", res.Added)
}
var existing existingLocalMedia
if err := repos.DB.Model(&model.Media{}).
Select("duration_sec", "width", "height", "video_codec", "audio_codec", "size_bytes").
Where("path = ?", file).Scan(&existing).Error; err != nil {
t.Fatal(err)
}
if !localMediaProbeDataMissing(existing) {
t.Fatalf("fixture precondition failed, media already has probe data: %+v", existing)
}
// A fresh scanner instance has no probe in flight for this path, which is what
// a later rescan (e.g. after the operator installs ffmpeg) looks like.
second := newProbeBackfillScanner(t, repos, true)
_, skipUnchanged := second.localMediaScanState(localMediaScanStateInput{
ctx: t.Context(),
path: file,
cleanPath: filepath.Clean(file),
ext: ".mkv",
size: existing.SizeBytes,
existingMedia: map[string]existingLocalMedia{filepath.Clean(file): existing},
})
if !skipUnchanged {
t.Fatal("unchanged file should take the incremental-skip path")
}
// Control: a different unprobed path on the same instance still queues, so the
// instance is demonstrably able to schedule probes.
other := filepath.Join(root, "Another Movie (2022).mkv")
if !second.queueProbeBackfillIfNeeded(other, ".mkv", existing) {
t.Fatal("control path should queue a backfill probe")
}
// Therefore the incremental-skip path above must already have queued this path:
// queueing reserves the path, so a second attempt is refused. If the skip path
// had not queued it, this call would have succeeded.
if second.queueProbeBackfillIfNeeded(file, ".mkv", existing) {
t.Fatal("unchanged unprobed media was not queued by the incremental-skip path")
}
}
+14 -7
View File
@@ -39,7 +39,7 @@ type SchedulerService struct {
cacheDir string
now func() time.Time
imagesMaxSizeMBProvider func() int
imagesPolicyProvider func() ImageCachePolicy
mu sync.Mutex
stopCh chan struct{}
@@ -59,15 +59,22 @@ func (s *SchedulerService) SetOrganizePipeline(pipeline *OrganizePipelineService
s.organizePipeline = pipeline
}
func (s *SchedulerService) SetImagesMaxSizeMBProvider(fn func() int) {
s.imagesMaxSizeMBProvider = fn
// ImageCachePolicy 是一次图片缓存清理要用的策略(全部为 0 表示不做任何清理)。
type ImageCachePolicy struct {
TotalBytes int64
OriginalsBytes int64
OriginalsAge time.Duration
}
func (s *SchedulerService) imagesMaxSizeMB() int {
if s.imagesMaxSizeMBProvider != nil {
return s.imagesMaxSizeMBProvider()
func (s *SchedulerService) SetImageCachePolicyProvider(fn func() ImageCachePolicy) {
s.imagesPolicyProvider = fn
}
func (s *SchedulerService) imageCachePolicy() ImageCachePolicy {
if s.imagesPolicyProvider != nil {
return s.imagesPolicyProvider()
}
return 0
return ImageCachePolicy{}
}
// scheduledJob is one recurring task.
+6 -5
View File
@@ -178,18 +178,19 @@ func (s *SchedulerService) jobCleanTranscodeCache(ctx context.Context) error {
return walkAndPrune(s.cacheDir+"/hls", cutoff)
}
// jobCleanImageCache prunes image proxy cache files when disk usage exceeds the configured limit.
// jobCleanImageCache prunes the image proxy cache: 原图按保留时长与独立配额
// 优先淘汰,总量超限时再淘汰派生成品。
func (s *SchedulerService) jobCleanImageCache(ctx context.Context) error {
if s.cacheDir == "" {
return nil
}
maxMB := s.imagesMaxSizeMB()
if maxMB <= 0 {
policy := s.imageCachePolicy()
if policy.TotalBytes <= 0 && policy.OriginalsBytes <= 0 && policy.OriginalsAge <= 0 {
return nil
}
imagesDir := filepath.Join(s.cacheDir, "images")
maxSizeBytes := int64(maxMB) * 1024 * 1024
res, err := PruneImageCache(imagesDir, maxSizeBytes)
pools := ImageCachePools(imagesDir, policy.OriginalsBytes, policy.OriginalsAge)
res, err := PruneImageCachePools(pools, policy.TotalBytes)
if err != nil {
if s.log != nil {
s.log.Warn("scheduled image cache cleanup failed", zap.Error(err))
+1 -1
View File
@@ -29,7 +29,7 @@ func (s *ScraperService) prepareScrapedArtworkURL(ctx context.Context, mediaID,
timeout = 8 * time.Second
}
fetchCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), timeout)
err := s.images.PrefetchRemote(fetchCtx, candidate)
err := s.images.PrefetchCardVariant(fetchCtx, candidate)
cancel()
if err != nil {
if originalSource != "" {
@@ -0,0 +1,68 @@
package service
import (
"strings"
"testing"
)
// TestCleanQueryPreservesOriginalTitleCase is the regression guard for English
// titles being lowercased on ingest: "The.Matrix.1999.1080p.BluRay.x264-AMIABLE"
// used to be stored as "the matrix", which also made metadata providers miss it.
func TestCleanQueryPreservesOriginalTitleCase(t *testing.T) {
cases := []struct {
in string
wantTitle string
wantYear int
}{
{"The.Matrix.1999.1080p.BluRay.x264-AMIABLE.mkv", "The Matrix", 1999},
{"The.Shawshank.Redemption.1994.1080p.BluRay.mkv", "The Shawshank Redemption", 1994},
{"Interstellar.2014.2160p.WEB-DL.mkv", "Interstellar", 2014},
{"Breaking.Bad.S01E01.1080p.WEB-DL.mkv", "Breaking Bad", 0},
{"Fast.and.Furious.2001.mkv", "Fast and Furious", 2001},
{"spider-man.2002.1080p.mkv", "spider man", 2002},
}
for _, tc := range cases {
t.Run(tc.in, func(t *testing.T) {
gotTitle, gotYear := CleanQuery(tc.in)
if gotTitle != tc.wantTitle || gotYear != tc.wantYear {
t.Errorf("CleanQuery(%q) = (%q, %d), want (%q, %d)",
tc.in, gotTitle, gotYear, tc.wantTitle, tc.wantYear)
}
})
}
}
// The release-tag rules run on lowercased tokens, so restoring the display case
// must not change which tokens survive. Anything that starts matching or
// dropping tokens only because of casing (e.g. patEP's boundary class seeing an
// uppercase letter) would show up here.
func TestCleanQueryCaseRestoreDoesNotChangeTokenSelection(t *testing.T) {
// Both spellings must select exactly the same title tokens.
pairs := [][2]string{
{"The.Matrix.1999.1080p.BluRay.mkv", "the.matrix.1999.1080p.bluray.mkv"},
{"SE7EN.1995.1080p.BluRay.mkv", "se7en.1995.1080p.bluray.mkv"},
{"WEB.Therapy.S01E01.1080p.mkv", "web.therapy.s01e01.1080p.mkv"},
{"For.All.Mankind.S05E06.4K.mkv", "for.all.mankind.s05e06.4k.mkv"},
}
for _, pair := range pairs {
upper, upperYear := CleanQuery(pair[0])
lower, lowerYear := CleanQuery(pair[1])
if !equalFoldASCII(upper, lower) || upperYear != lowerYear {
t.Errorf("case changed token selection: %q -> (%q, %d) vs %q -> (%q, %d)",
pair[0], upper, upperYear, pair[1], lower, lowerYear)
}
}
}
func equalFoldASCII(a, b string) bool {
return strings.EqualFold(a, b)
}
// A filename with no case information (all lowercase, as many release names are)
// must stay as-is rather than being title-cased: MeBox cannot invent casing.
func TestCleanQueryKeepsLowercaseInputLowercase(t *testing.T) {
title, _ := CleanQuery("the.matrix.1999.1080p.mkv")
if title != "the matrix" {
t.Fatalf("CleanQuery = %q, want %q", title, "the matrix")
}
}
+44 -1
View File
@@ -172,10 +172,53 @@ func CleanQuery(raw string) (title string, year int) {
}
out = append(out, w)
}
title = strings.TrimSpace(strings.Join(out, " "))
title = strings.TrimSpace(strings.Join(restoreQueryTokenCase(name, out), " "))
return title, year
}
// queryTokenSeparators mirrors the separator split used while tokenising a
// cleaned filename.
var queryTokenSeparators = []string{".", "_", "-", "[", "]", "(", ")", "×"}
// restoreQueryTokenCase maps the surviving lowercase tokens of a cleaned
// filename back onto the spelling used in the original name.
//
// Tokenisation itself stays case-insensitive: the release-tag rules in this file
// rely on lowercased tokens (and patEP's [^a-z] boundary would misfire on
// "SE7EN"-style names if it ran on mixed case). Rebuilding the display title from
// the original tokens afterwards keeps "The.Matrix.1999.1080p..." as "The Matrix"
// instead of overwriting the user's media with a lowercased title — the lowercased
// form also used to poison metadata lookups. Tokens with no original counterpart
// (fragments produced by a removal) fall back to their lowercase form.
func restoreQueryTokenCase(name string, tokens []string) []string {
if len(tokens) == 0 {
return tokens
}
originalByLower := make(map[string]string, len(tokens))
normalized := bracketedTag.ReplaceAllString(name, " ")
for _, sep := range queryTokenSeparators {
normalized = strings.ReplaceAll(normalized, sep, " ")
}
for _, token := range strings.Fields(normalized) {
key := strings.ToLower(token)
if key == "" || key == releaseSignalToken {
continue
}
if _, exists := originalByLower[key]; !exists {
originalByLower[key] = token
}
}
cased := make([]string, 0, len(tokens))
for _, token := range tokens {
if original, ok := originalByLower[token]; ok {
cased = append(cased, original)
continue
}
cased = append(cased, token)
}
return cased
}
func isASCIIWord(s string) bool {
if s == "" {
return false
@@ -12,10 +12,10 @@ func TestCleanQueryKeepsCommonEnglishTitleWords(t *testing.T) {
wantTitle string
wantYear int
}{
{"Mad.Max.1979.1080p.BluRay.mkv", "mad max", 1979},
{"Max.Payne.2008.1080p.WEB-DL.mkv", "max payne", 2008},
{"Web.Therapy.S01E01.1080p.WEB-DL.mkv", "web therapy", 0},
{"The.Web.2019.1080p.mkv", "the web", 2019},
{"Mad.Max.1979.1080p.BluRay.mkv", "Mad Max", 1979},
{"Max.Payne.2008.1080p.WEB-DL.mkv", "Max Payne", 2008},
{"Web.Therapy.S01E01.1080p.WEB-DL.mkv", "Web Therapy", 0},
{"The.Web.2019.1080p.mkv", "The Web", 2019},
}
for _, tc := range cases {
t.Run(tc.in, func(t *testing.T) {
@@ -43,8 +43,8 @@ func TestCleanQueryDoesNotTruncateAmbiguousTagToNothing(t *testing.T) {
// truncation but must not discard the real title words that follow it.
func TestCleanQueryKeepsTitleAfterYearPrefix(t *testing.T) {
cases := map[string]string{
"2019.Avatar.1080p.BluRay.mkv": "avatar",
"2024.Dune.Part.Two.2160p.WEB.mkv": "dune part two",
"2019.Avatar.1080p.BluRay.mkv": "Avatar",
"2024.Dune.Part.Two.2160p.WEB.mkv": "Dune Part Two",
}
for in, want := range cases {
t.Run(in, func(t *testing.T) {
@@ -62,9 +62,9 @@ func TestCleanQueryKeepsTitleAfterYearPrefix(t *testing.T) {
func TestCleanQueryStillDropsTagsAfterReleaseSignal(t *testing.T) {
cases := map[string]string{
"复仇者联盟4.2019.BD.1080p.mkv": "复仇者联盟4",
"The.Matrix.1999.1080p.WEB-DL.H265.mp4": "the matrix",
"Oppenheimer.2023.2160p.UHD.BluRay.mkv": "oppenheimer",
"Interstellar.2014.4k.hdr.dts.atmos.mkv": "interstellar",
"The.Matrix.1999.1080p.WEB-DL.H265.mp4": "The Matrix",
"Oppenheimer.2023.2160p.UHD.BluRay.mkv": "Oppenheimer",
"Interstellar.2014.4k.hdr.dts.atmos.mkv": "Interstellar",
}
for in, want := range cases {
t.Run(in, func(t *testing.T) {
+8 -5
View File
@@ -30,7 +30,7 @@ func TestScrapeQueryCandidatesUseMovieFolderForGenericFilename(t *testing.T) {
if len(got) == 0 {
t.Fatal("scrapeQueryCandidates returned no candidates")
}
if got[0] != "inception" {
if got[0] != "Inception" {
t.Fatalf("first query candidate = %q, want movie folder title; all candidates=%#v", got[0], got)
}
for _, candidate := range got {
@@ -55,7 +55,7 @@ func TestScrapeQueryCandidatesUseMovieLibraryRootWhenMountedAtMovieFolder(t *tes
if len(got) == 0 {
t.Fatal("scrapeQueryCandidates returned no candidates")
}
if got[0] != "inception" {
if got[0] != "Inception" {
t.Fatalf("first query candidate = %q, want movie library root title; all candidates=%#v", got[0], got)
}
}
@@ -73,7 +73,7 @@ func TestScrapeQueryCandidatesDoNotUseMovieCollectionFolderAsTitle(t *testing.T)
if len(got) == 0 {
t.Fatal("scrapeQueryCandidates returned no candidates")
}
if got[0] != "the hunger games catching fire" {
if got[0] != "The Hunger Games Catching Fire" {
t.Fatalf("first query candidate = %q, want individual movie title; all candidates=%#v", got[0], got)
}
for _, candidate := range got {
@@ -92,7 +92,10 @@ func TestEnrichOneUsesMovieFolderWhenFilenameIsGeneric(t *testing.T) {
http.NotFound(w, r)
return
}
if r.URL.Query().Get("query") != "inception" {
// Real metadata providers treat the query case-insensitively; this stub
// must too, otherwise it would only be pinned to the historical lowercased
// query spelling instead of the folder-fallback behaviour it guards.
if !strings.EqualFold(r.URL.Query().Get("query"), "inception") {
_ = json.NewEncoder(w).Encode(map[string]any{"results": []any{}})
return
}
@@ -149,7 +152,7 @@ func TestEnrichOneUsesMovieFolderWhenFilenameIsGeneric(t *testing.T) {
if got.ScrapeStatus != "matched" || got.TMDbID != 27205 || got.Title != "Inception" {
t.Fatalf("generic filename scrape did not use folder title: status=%q tmdb=%d title=%q queries=%v", got.ScrapeStatus, got.TMDbID, got.Title, queries)
}
if len(queries) == 0 || queries[0] != "inception" {
if len(queries) == 0 || !strings.EqualFold(queries[0], "inception") {
t.Fatalf("first tmdb query = %q, want folder title; all queries=%v", firstQuery(queries), queries)
}
}
+11 -11
View File
@@ -23,19 +23,19 @@ func TestCleanQuery(t *testing.T) {
wantTitle string
wantYear int
}{
{"Inception.2010.1080p.BluRay.x264.mkv", "inception", 2010},
{"The_Matrix_(1999).1080p.WEB-DL.H265.mp4", "the matrix", 1999},
{"Inception.2010.1080p.BluRay.x264.mkv", "Inception", 2010},
{"The_Matrix_(1999).1080p.WEB-DL.H265.mp4", "The Matrix", 1999},
{"interstellar.2014.4k.hdr.dts.atmos.mkv", "interstellar", 2014},
{"My Movie 2022 [HDR] (1080p) [TGx].mp4", "my movie", 2022},
{"NoYearOrTags.mkv", "noyearortags", 0},
{"亏成首富从游戏开始 The Richest in Game - S01E11 - 4K.mp4", "亏成首富从游戏开始 the richest in game", 0},
{"My Movie 2022 [HDR] (1080p) [TGx].mp4", "My Movie", 2022},
{"NoYearOrTags.mkv", "NoYearOrTags", 0},
{"亏成首富从游戏开始 The Richest in Game - S01E11 - 4K.mp4", "亏成首富从游戏开始 The Richest in Game", 0},
{"紫川.2024.S02E24.第24集.2160p.WEB-DL.H.265-ColorTV.mkv", "紫川", 2024},
{"紫川 (2024) {tmdb-247590}", "紫川", 2024},
{"HNTV.Spring.Festival.Gala.FPS.HLG-QHStudio.S01E202-DD5.QHstudIo.6.4K.ts", "hntv spring festival gala", 0},
{"Hntv Spring Festival Gala S01e (2026)", "hntv spring festival gala", 2026},
{"Motherhood Of Taihang Aac2 Mweb - S01E01-Aac2.Mweb.mkv", "motherhood of taihang", 0},
{"For All Mankind Atvp Hhweb - S05E06-DDP5.HHWEB.4K.mkv", "for all mankind", 0},
{"Hntv Spring Festival Gala Fps Hlg Qhstudio S01e (2026)", "hntv spring festival gala", 2026},
{"HNTV.Spring.Festival.Gala.FPS.HLG-QHStudio.S01E202-DD5.QHstudIo.6.4K.ts", "HNTV Spring Festival Gala", 0},
{"Hntv Spring Festival Gala S01e (2026)", "Hntv Spring Festival Gala", 2026},
{"Motherhood Of Taihang Aac2 Mweb - S01E01-Aac2.Mweb.mkv", "Motherhood Of Taihang", 0},
{"For All Mankind Atvp Hhweb - S05E06-DDP5.HHWEB.4K.mkv", "For All Mankind", 0},
{"Hntv Spring Festival Gala Fps Hlg Qhstudio S01e (2026)", "Hntv Spring Festival Gala", 2026},
}
for _, tc := range cases {
t.Run(tc.in, func(t *testing.T) {
@@ -75,7 +75,7 @@ func TestScrapeQueryCandidatesCleanDirtySeriesFolder(t *testing.T) {
if len(got) == 0 {
t.Fatal("scrapeQueryCandidates returned no candidates")
}
if got[0] != "motherhood of taihang" {
if got[0] != "Motherhood Of Taihang" {
t.Fatalf("first query candidate = %q, want cleaned series title; all candidates=%#v", got[0], got)
}
for _, candidate := range got {
@@ -1,6 +1,7 @@
package service
import (
"bytes"
"context"
"os"
"path/filepath"
@@ -152,6 +153,13 @@ func (s *ScraperService) writeArtworkDataToPath(dir, name, ctype string, data []
return ""
}
dst := filepath.Join(dir, name+imageExtForContentType(ctype))
// 内容与现有文件完全一致时不重写:重写会刷新源文件 mtime,而缩略图缓存键
// 包含源文件大小与 mtime,一次「内容没变」的重新刮削会让该条目所有尺寸的
// 缩略图一并作废,下次浏览全部重新解码。
if artworkFileUnchanged(dst, data) {
s.log.Debug("scrape artwork unchanged", zap.String("dst", dst))
return dst
}
tmp, err := os.CreateTemp(dir, "img-*.tmp")
if err != nil {
s.log.Warn("scrape artwork temp create failed", zap.String("dir", dir), zap.Error(err))
@@ -186,6 +194,26 @@ func (s *ScraperService) writeArtworkDataToPath(dir, name, ctype string, data []
return dst
}
// artworkFileUnchanged 报告 path 是否已经就是 data 这些字节。
//
// 刮削在刷新海报地址时会无条件重写 sidecar 图片,即使下载回来的字节一模一样。
// 缩略图缓存键包含源文件的大小与 mtime,因此这种无谓的重写会让该条目所有尺寸
// 的缩略图一起失效;保持一致时直接复用原文件可以保住 mtime。
func artworkFileUnchanged(path string, data []byte) bool {
if len(data) == 0 {
return false
}
info, err := os.Stat(path)
if err != nil || info.IsDir() || info.Size() != int64(len(data)) {
return false
}
existing, err := os.ReadFile(path) // #nosec G304 -- path is built from a sanitized media directory.
if err != nil {
return false
}
return bytes.Equal(existing, data)
}
// imageExtForContentType maps a detected image MIME type to a file extension.
// Unknown image types fall back to a generic ".img" so we never write an empty
// extension that could confuse media players.
@@ -9,6 +9,7 @@ import (
"os"
"path/filepath"
"testing"
"time"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
@@ -62,6 +63,47 @@ func TestWriteArtworkDataToPathReplacesExistingSidecar(t *testing.T) {
}
}
// TestWriteArtworkDataToPathSkipsUnchangedSidecar 验证"内容一致就不重写":
// 缩略图缓存键包含源文件的大小与 mtime,无谓的重写会让该条目所有尺寸的缩略图
// 一起失效,下次浏览全部重新解码。
func TestWriteArtworkDataToPathSkipsUnchangedSidecar(t *testing.T) {
scraper := &ScraperService{log: zap.NewNop()}
mediaDir := t.TempDir()
dst := scraper.writeArtworkDataToPath(mediaDir, "poster", "image/jpeg", testJPEG)
if dst == "" {
t.Fatal("expected a written destination path")
}
// 把 mtime 拨回过去,再写入完全相同的字节:文件必须原样保留。
past := time.Now().Add(-2 * time.Hour)
if err := os.Chtimes(dst, past, past); err != nil {
t.Fatal(err)
}
if got := scraper.writeArtworkDataToPath(mediaDir, "poster", "image/jpeg", testJPEG); got != dst {
t.Fatalf("destination = %q, want %q", got, dst)
}
info, err := os.Stat(dst)
if err != nil {
t.Fatal(err)
}
if time.Since(info.ModTime()) < time.Minute {
t.Fatalf("mtime = %v, want the original past timestamp (identical artwork was rewritten)", info.ModTime())
}
// 内容变化时仍然必须覆盖。
changed := append([]byte(nil), testJPEG...)
changed[len(changed)-1] = 0x00
if got := scraper.writeArtworkDataToPath(mediaDir, "poster", "image/jpeg", changed); got != dst {
t.Fatalf("destination = %q, want %q", got, dst)
}
data, err := os.ReadFile(dst)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(data, changed) {
t.Fatal("changed artwork was not written")
}
}
func TestShouldCropAdultPosterWhenTMDbArtworkMatchedCodePath(t *testing.T) {
media := &model.Media{
Path: filepath.Join(t.TempDir(), "IPX-235.mp4"),
+15 -3
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"strings"
"time"
"go.uber.org/zap"
@@ -120,6 +121,8 @@ func (b *serviceContainerBuilder) initContentServices() {
b.c.Audit = NewAuditService(b.log, b.repos)
b.c.Strm = NewStrmService(b.cfg, b.log, b.repos, b.c.Crypto)
b.c.Cloud115 = NewCloud115PlaybackService(b.cfg, b.log, b.repos, b.c.Strm)
// 本地 HLS 档位的可用性取决于 ffmpeg 是否可用;未注入时按不可用处理。
b.c.Cloud115.SetTranscoder(b.c.Transcoder)
// ffmpeg/ffprobe 一键下载安装(data/tools/ffmpeg/)。
b.c.FFTools = NewFFmpegToolsService(b.cfg, b.log, b.repos)
// 弹幕 hash 识别需要把 strm 指向解析成可拉取的直链/本地路径。
@@ -164,11 +167,20 @@ func (b *serviceContainerBuilder) initAccessAndStorageServices() {
)
b.c.Scheduler.SetTaskTracker(b.c.Tasks)
b.c.Scheduler.SetOrganizePipeline(b.c.OrganizePipeline)
b.c.Scheduler.SetImagesMaxSizeMBProvider(func() int {
b.c.Scheduler.SetImageCachePolicyProvider(func() ImageCachePolicy {
if b.cfg == nil {
return 0
return ImageCachePolicy{}
}
return b.cfg.Cache.ImagesMaxSizeMB
policy := ImageCachePolicy{
TotalBytes: int64(b.cfg.Cache.ImagesMaxSizeMB) * 1024 * 1024,
}
if b.cfg.Cache.ImagesOriginalsMaxSizeMB > 0 {
policy.OriginalsBytes = int64(b.cfg.Cache.ImagesOriginalsMaxSizeMB) * 1024 * 1024
}
if b.cfg.Cache.ImagesOriginalsTTLHours > 0 {
policy.OriginalsAge = time.Duration(b.cfg.Cache.ImagesOriginalsTTLHours) * time.Hour
}
return policy
})
}
+3 -2
View File
@@ -33,8 +33,9 @@ func (s *StreamService) ServeFileWithCloudMode(w http.ResponseWriter, r *http.Re
if !cloudPlaybackModeEnabled(r.Context(), s.repo, cloudMode) {
return ErrCloudPlaybackDisabled
}
// 云盘播放 URL 先规范化为相对路径,免疫扫描时固化的旧 host。
target := normalizeCloudPlayTarget(strmURL)
// 云盘播放 URL 先规范化为相对路径,免疫扫描时固化的旧 host;
// 指向别的 MeBox 实例的地址保持原样,按第三方直链透传。
target := normalizeCloudPlayTarget(r.Context(), s.repo, s.cfg, r, strmURL)
target = withAuthTokenForInternalRedirect(target, r, PublicServerURL(r.Context(), s.repo, s.cfg))
setCloudRedirectNoStore(w)
http.Redirect(w, r, absoluteInternalRedirect(target, r), http.StatusFound)
+85 -9
View File
@@ -1,19 +1,28 @@
package service
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/truewhile/MeBox/internal/model"
)
// TestNormalizeCloudPlayTarget 验证存库的云盘播放 URL(可能携带扫描时的
// 旧 host)被规范化为相对路径,使 302 始终基于当前请求地址构造。
// TestNormalizeCloudPlayTarget 验证存库的云盘播放 URL(可能携带扫描时的旧 host)
// 被规范化为相对路径,使 302 始终基于当前请求地址构造。
func TestNormalizeCloudPlayTarget(t *testing.T) {
ref := "/电影/某部影片 (2024)/movie.mkv"
stale := "http://192.168.1.4:9011/api/cloud/play/openlist?ref=" + url.QueryEscape(ref)
got := normalizeCloudPlayTarget(stale)
req := httptest.NewRequest(http.MethodGet, "http://nas.local:18080/api/stream/media-1", nil)
ctx := context.Background()
// 相对路径:本来就是本机形态,按 provider+ref 重建(保持相对)。
relative := "/api/cloud/play/openlist?ref=" + url.QueryEscape(ref)
got := normalizeCloudPlayTarget(ctx, nil, nil, req, relative)
want := BuildRelativeCloudPlayURL("openlist", ref)
if got != want {
t.Fatalf("normalizeCloudPlayTarget = %q, want %q", got, want)
t.Fatalf("normalizeCloudPlayTarget(relative) = %q, want %q", got, want)
}
parsed, err := url.Parse(got)
if err != nil {
@@ -26,16 +35,83 @@ func TestNormalizeCloudPlayTarget(t *testing.T) {
t.Fatalf("ref round-trip failed: %q", parsed.Query().Get("ref"))
}
strmStale := "http://bwg.linkmy.fun:1314/api/strm/play/cloud115/video.mkv?acct=abc&pickcode=123"
gotStrm := normalizeCloudPlayTarget(strmStale)
// 绝对地址但 host 就是当前请求 host:宿主切换过(开发机扫描 → 部署)
// 之后仍要能当作本机地址处理。
sameHost := "http://nas.local:18080/api/strm/play/cloud115/video.mkv?acct=abc&pickcode=123"
gotStrm := normalizeCloudPlayTarget(ctx, nil, nil, req, sameHost)
wantStrm := "/api/strm/play/cloud115/video.mkv?acct=abc&pickcode=123"
if gotStrm != wantStrm {
t.Fatalf("normalizeCloudPlayTarget(strm) = %q, want %q", gotStrm, wantStrm)
t.Fatalf("normalizeCloudPlayTarget(same host) = %q, want %q", gotStrm, wantStrm)
}
// 非云盘播放 URL 保持原样(WebDAV/直链等)。
passthrough := "https://dav.example.com/media/file.mkv"
if got := normalizeCloudPlayTarget(passthrough); got != passthrough {
if got := normalizeCloudPlayTarget(ctx, nil, nil, req, passthrough); got != passthrough {
t.Fatalf("non-cloud target should pass through, got %q", got)
}
}
// TestNormalizeCloudPlayTargetKeepsForeignInstanceURL 验证别的 MeBox /
// MediaStationGo 实例生成的 .strm 内容不被本机账号解析,而是按第三方直链透传。
func TestNormalizeCloudPlayTargetKeepsForeignInstanceURL(t *testing.T) {
svc := testStrmService(t)
ctx := context.Background()
// 本机自己的 115 账号(strm.base_url 由 testStrmService 设为 http://test.local:8096)。
own := &model.StrmAccount{
Base: model.Base{ID: "acct-own-115"},
Name: "own",
Provider: model.StrmProvider115,
Enabled: true,
}
if err := svc.repo.StrmAccount.Create(ctx, own); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "http://mebox.local/api/stream/media-1", nil)
// host 不认识 + acct 不是本机账号 → 另一个实例的 .strm,原样透传。
foreign := "http://other-mebox.example:18080/api/strm/play/cloud115/video.mkv?acct=acct-other&pickcode=xyz"
if got := normalizeCloudPlayTarget(ctx, svc.repo, nil, req, foreign); got != foreign {
t.Fatalf("foreign instance url should pass through, got %q", got)
}
// host 不认识但 acct 是本机账号 → 本机换了域名/IP 的老 .strm,仍要认领。
staleOwn := "http://192.168.1.4:9011/api/strm/play/cloud115/video.mkv?acct=acct-own-115&pickcode=123"
wantStale := "/api/strm/play/cloud115/video.mkv?acct=acct-own-115&pickcode=123"
if got := normalizeCloudPlayTarget(ctx, svc.repo, nil, req, staleOwn); got != wantStale {
t.Fatalf("own acct on stale host = %q, want %q", got, wantStale)
}
// acct 撞上本机账号 ID 但账号类型与路径 provider 不一致:不算本机。
wrongProvider := "http://other-mebox.example/api/strm/play/openlist/video.mkv?acct=acct-own-115&pickcode=123"
if got := normalizeCloudPlayTarget(ctx, svc.repo, nil, req, wrongProvider); got != wrongProvider {
t.Fatalf("provider mismatch should pass through, got %q", got)
}
}
// TestNormalizeCloudPlayTargetLegacyCloudURL 旧格式 /api/cloud/play(不带 acct)
// 无法凭账号 ID 判断归属:本机配了该类型账号就按本机处理(保住老固化地址的可
// 播放性),完全没配才透传。
func TestNormalizeCloudPlayTargetLegacyCloudURL(t *testing.T) {
svc := testStrmService(t)
ctx := context.Background()
req := httptest.NewRequest(http.MethodGet, "http://mebox.local/api/stream/media-1", nil)
ref := "/Movies/Movie.mkv"
legacy := "http://old-host:9011/api/cloud/play/openlist?ref=" + url.QueryEscape(ref)
// 本机没有 openlist 账号 → 不是本机地址。
if got := normalizeCloudPlayTarget(ctx, svc.repo, nil, req, legacy); got != legacy {
t.Fatalf("legacy url without local provider should pass through, got %q", got)
}
if err := svc.repo.StrmAccount.Create(ctx, &model.StrmAccount{
Base: model.Base{ID: "acct-openlist"},
Name: "openlist",
Provider: model.StrmProviderOpenList,
Enabled: true,
}); err != nil {
t.Fatal(err)
}
if got := normalizeCloudPlayTarget(ctx, svc.repo, nil, req, legacy); got != BuildRelativeCloudPlayURL("openlist", ref) {
t.Fatalf("legacy url with local provider = %q, want relative rebuild", got)
}
}
+184 -11
View File
@@ -1,9 +1,13 @@
package service
import (
"context"
"net/http"
"net/url"
"strings"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/repository"
)
// normalizeCloudPlayTarget 把存库的云盘播放 URL 规范化为相对路径。
@@ -11,28 +15,197 @@ import (
// STRMURL 是扫描时根据当时的 server_url/请求地址生成并固化进数据库的。
// 在 Windows 开发机上扫描、再部署到 Docker(或更换了内网 IP/域名)后,
// 这些绝对 URL 会指向已失效的旧地址,第三方播放器跟随 302 就会拿到
// 连接失败/404。这里只要能从 URL 中解析出 provider+ref,就重建为相对
// /api/cloud/play 路径,由 absoluteInternalRedirect 基于「当前请求」补全
// host,从而对历史脏数据免疫。
func normalizeCloudPlayTarget(raw string) string {
// 连接失败/404。所以只要确认这个地址「是本机自己生成的」,就从 URL 中解析出
// provider+ref,重建为相对 /api/cloud/play 或 /api/strm/play 路径,由
// absoluteInternalRedirect 基于「当前请求」补全 host,从而对历史脏数据免疫。
//
// 反过来,指向**别的 MeBox / MediaStationGo 实例**的地址不能按本机账号解析:
// 别人 .strm 里的 acct 是他那台机器的账号 ID,拿到本机来查只会得到
// 「网盘账号不存在」。这种地址按普通第三方直链原样透传,让客户端跟着 302 去
// 对方实例取流(/api/strm/play 是公开端点,不需要本机凭据),或直接去对方的
// CDN 直链。归属判断见 isInternalPlaybackTarget。
func normalizeCloudPlayTarget(ctx context.Context, repo *repository.Container, cfg *config.Config, r *http.Request, raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return raw
}
if !isInternalPlaybackTarget(ctx, repo, cfg, r, raw) {
return raw
}
if typ, ref, ok := parseCloudMediaPlaybackURL(raw); ok {
return BuildRelativeCloudPlayURL(typ, ref)
}
if u, err := url.Parse(raw); err == nil {
path := strings.ToLower(u.Path)
if strings.HasPrefix(path, "/api/strm/play/") || strings.HasPrefix(path, "/api/cloud/play/") || strings.HasPrefix(path, "/api/stream/") {
u.Scheme = ""
u.Host = ""
return u.String()
}
u, err := url.Parse(raw)
if err != nil {
return raw
}
path := strings.ToLower(u.Path)
if strings.HasPrefix(path, "/api/strm/play/") || strings.HasPrefix(path, "/api/cloud/play/") || strings.HasPrefix(path, "/api/stream/") {
u.Scheme = ""
u.Host = ""
return u.String()
}
return raw
}
// isInternalPlaybackTarget 判断播放地址能否按「本机自己的云盘播放地址」处理
// (相对化 + 用本机账号解析)。判断顺序:
//
// 1. 相对路径一定是本机存库的常规形态;
// 2. 绝对地址的 host 与本机配置的 strm.base_url、各同步目录覆盖的 base_url、
// 当前请求 host 之一相同 → 就是本机(老 .strm 里固化的旧 host 属于这一类);
// 3. host 对不上时,再看地址里带的网盘账号 ID 是不是本机账号:MeBox /
// MediaStationGo 会把本机账号 ID 写进 acct,而账号 ID 由各实例自行生成,
// 跨实例几乎不可能撞号。这条用于「换了域名/IP 之后」认领自己的老 .strm。
//
// 三条都不满足(典型是另一台 MeBox 生成的 .strm)→ 视为第三方直链。
func isInternalPlaybackTarget(ctx context.Context, repo *repository.Container, cfg *config.Config, r *http.Request, raw string) bool {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil || u == nil {
return false
}
if u.Host == "" {
return true
}
if !isPlaybackAPIPath(u.Path) {
return false
}
if matchesLocalPlaybackHost(ctx, repo, cfg, r, u) {
return true
}
return localAccountOwnsPlaybackTarget(ctx, repo, u)
}
// isPlaybackAPIPath 判断路径是不是本服务自己的播放端点。
func isPlaybackAPIPath(path string) bool {
lower := strings.ToLower(strings.TrimSpace(path))
for _, prefix := range []string{"/api/strm/play/", "/api/cloud/play/", "/api/stream/"} {
if strings.HasPrefix(lower, prefix) {
return true
}
}
return false
}
// matchesLocalPlaybackHost 判断绝对播放地址的 host 是否就是本机。
func matchesLocalPlaybackHost(ctx context.Context, repo *repository.Container, cfg *config.Config, r *http.Request, u *url.URL) bool {
if u == nil {
return false
}
target := canonicalHostPort(u)
if target == "" {
return false
}
for _, base := range localPlaybackBaseURLs(ctx, repo, cfg, r) {
parsed, err := url.Parse(base)
if err != nil || parsed == nil {
continue
}
if canonicalHostPort(parsed) == target {
return true
}
}
return false
}
// localPlaybackBaseURLs 汇总本机的播放基地址:管理员配置的公网地址 /
// strm.base_url、每条 STRM 同步目录单独覆盖的 base_url,以及当前请求的 host
// (含反向代理头)。请求 host 也要算进来:没有配 base_url 时 .strm 里固化的
// 就是请求地址。
func localPlaybackBaseURLs(ctx context.Context, repo *repository.Container, cfg *config.Config, r *http.Request) []string {
bases := make([]string, 0, 4)
if base := PublicServerURL(ctx, repo, cfg); base != "" {
bases = append(bases, base)
}
if repo != nil && repo.StrmSyncPath != nil {
if paths, err := repo.StrmSyncPath.List(ctx); err == nil {
for i := range paths {
if override := strings.TrimSpace(paths[i].StrmBaseURL); override != "" {
bases = append(bases, override)
}
}
}
}
if r != nil {
if host := strings.TrimSpace(r.Host); host != "" {
bases = append(bases, "//"+host)
}
for _, header := range []string{"X-Forwarded-Host", "X-Original-Host"} {
if host := strings.TrimSpace(r.Header.Get(header)); host != "" {
bases = append(bases, "//"+host)
}
}
}
return bases
}
// canonicalHostPort 归一化 host[:port]:小写、忽略默认端口(http 80 / https 443)。
func canonicalHostPort(u *url.URL) string {
if u == nil {
return ""
}
host := strings.ToLower(strings.TrimSpace(u.Hostname()))
if host == "" {
return ""
}
port := strings.TrimSpace(u.Port())
if port == "" {
return host
}
scheme := strings.ToLower(strings.TrimSpace(u.Scheme))
if (scheme == "http" || scheme == "") && port == "80" {
return host
}
if scheme == "https" && port == "443" {
return host
}
return host + ":" + port
}
// localAccountOwnsPlaybackTarget 用「地址里带的网盘账号是不是本机的」判断播放
// 地址归属。账号 ID 跨实例不会撞号,因此比 host 更可靠:
//
// - /api/strm/play/{provider}/video{ext}?acct=…:acct 能在本机查到、且账号类型
// 与路径里的 provider 一致,才认作本机地址;
// - /api/cloud/play/{provider}?ref=…(旧格式,不带 acct):只能退化为「本机是否
// 配置了该类型账号」,配置了才按本机处理,保持老版本固化地址的可播放性。
func localAccountOwnsPlaybackTarget(ctx context.Context, repo *repository.Container, u *url.URL) bool {
if repo == nil || repo.StrmAccount == nil || u == nil {
return false
}
segments := strings.Split(strings.Trim(strings.TrimSpace(u.Path), "/"), "/")
if len(segments) < 4 || !strings.EqualFold(segments[0], "api") || !strings.EqualFold(segments[2], "play") {
return false
}
provider := strings.TrimSpace(segments[3])
if provider == "" {
return false
}
switch strings.ToLower(segments[1]) {
case "strm":
accountID := strings.TrimSpace(u.Query().Get("acct"))
if accountID == "" {
return false
}
account, err := repo.StrmAccount.FindByID(ctx, accountID)
if err != nil || account == nil {
return false
}
return strings.EqualFold(strings.TrimSpace(account.Provider), provider)
case "cloud":
accounts, err := repo.StrmAccount.List(ctx)
if err != nil {
return false
}
for i := range accounts {
if strings.EqualFold(strings.TrimSpace(accounts[i].Provider), provider) {
return true
}
}
}
return false
}
// BuildRelativeCloudPlayURL 构造相对的云盘播放 API 路径。
func BuildRelativeCloudPlayURL(typ, ref string) string {
return "/api/cloud/play/" + url.PathEscape(strings.TrimSpace(typ)) + "?" + url.Values{"ref": []string{ref}}.Encode()
+72
View File
@@ -216,6 +216,78 @@ func TestServeFileRedirectsLocalSTRMFileTargetByDefault(t *testing.T) {
}
}
// 别的 MeBox / MediaStationGo 实例生成的 .strm:里面的 acct 是对方实例的账号,
// 本机不能拿自己的账号去解析,直接把 302 透传给客户端,由客户端去对方实例取流。
func TestServeFilePassesThroughForeignInstanceSTRMURL(t *testing.T) {
repos := newStreamTestRepo(t)
target := "http://other-mebox.example:18080/api/strm/play/cloud115/video.mkv?acct=other-acct&pickcode=xyz"
if err := repos.DB.Create(&model.Media{
Base: model.Base{ID: "foreign-strm"},
Title: "Foreign STRM",
Path: "D:/media/Foreign.strm",
Container: "strm",
STRMURL: target,
}).Error; err != nil {
t.Fatal(err)
}
svc := NewStreamService(&config.Config{}, zap.NewNop(), repos, nil)
req := httptest.NewRequest(http.MethodGet, "http://nas.local:18080/api/stream/foreign-strm?token=jwt123", nil)
w := httptest.NewRecorder()
if err := svc.ServeFile(w, req, "foreign-strm"); err != nil {
t.Fatalf("foreign instance strm url should be passed through: %v", err)
}
if w.Code != http.StatusFound {
t.Fatalf("status = %d, want 302", w.Code)
}
loc := w.Header().Get("Location")
if loc != target {
t.Fatalf("Location = %q, want untouched %q", loc, target)
}
if strings.Contains(loc, "jwt123") {
t.Fatalf("foreign instance url must not receive our auth token, got %q", loc)
}
}
// 本机自己生成的 .strm 在换了域名/IP 之后仍要认领:host 对不上,但 acct 是本机
// 网盘账号,于是按当前请求 host 相对化,保持可播放。
func TestServeFileRealignsOwnSTRMURLOtherHost(t *testing.T) {
repos := repository.New(newServiceTestDB(t, &model.Media{}, &model.Setting{}, &model.StrmAccount{}))
if err := repos.StrmAccount.Create(t.Context(), &model.StrmAccount{
Base: model.Base{ID: "own-acct"},
Name: "own",
Provider: model.StrmProvider115,
Enabled: true,
}); err != nil {
t.Fatal(err)
}
if err := repos.DB.Create(&model.Media{
Base: model.Base{ID: "own-strm"},
Title: "Own STRM",
Path: "D:/media/Own.strm",
Container: "strm",
STRMURL: "http://old-host:9011/api/strm/play/cloud115/video.mkv?acct=own-acct&pickcode=123",
}).Error; err != nil {
t.Fatal(err)
}
svc := NewStreamService(&config.Config{}, zap.NewNop(), repos, nil)
req := httptest.NewRequest(http.MethodGet, "http://nas.local:18080/api/stream/own-strm?token=jwt123", nil)
w := httptest.NewRecorder()
if err := svc.ServeFile(w, req, "own-strm"); err != nil {
t.Fatalf("own strm url on a stale host should still play: %v", err)
}
if w.Code != http.StatusFound {
t.Fatalf("status = %d, want 302", w.Code)
}
loc := w.Header().Get("Location")
if !strings.HasPrefix(loc, "http://nas.local:18080/api/strm/play/cloud115/video.mkv?") ||
!strings.Contains(loc, "acct=own-acct") ||
!strings.Contains(loc, "pickcode=123") {
t.Fatalf("own strm url should be realigned to current host, got %q", loc)
}
}
func TestCloudPlaybackModeUsesExplicitModeBeforeLegacySTRMFlag(t *testing.T) {
repos := newStreamTestRepo(t)
if got := CloudPlaybackMode(t.Context(), repos); got != CloudPlaybackModeRedirectProxy {
+22 -1
View File
@@ -121,7 +121,7 @@ func (s *StrmService) resolveLocalPlay(ctx context.Context, rawPath string) (*St
// 支持:
// - /api/strm/play/{provider}/video{ext}?acct=..&pickcode=.. (常规格式,含账号)
// - /api/cloud/play/{type}?ref=.. (旧格式,无账号 → 取该类型第一个启用账号)
// - 绝对 http(s) 链接(直接透传)
// - 绝对 http(s) 链接(直接透传,包含别的 MeBox / MediaStationGo 实例的播放端点)
// - 其余协议(webdav:// 等)返回错误,由调用方决定是否静默跳过
func (s *StrmService) ResolvePlayTarget(ctx context.Context, raw string) (*StrmPlayResult, error) {
raw = strings.TrimSpace(raw)
@@ -132,6 +132,17 @@ func (s *StrmService) ResolvePlayTarget(ctx context.Context, raw string) (*StrmP
if err != nil {
return nil, fmt.Errorf("解析播放目标失败: %w", err)
}
// 别的 MeBox / MediaStationGo 实例的播放端点:本机没有对应账号,按普通外部
// 直链处理——客户端与 ffmpeg 直接跟随 302 去对方实例取流(/api/strm/play 是
// 公开端点,不需要本机凭据),不能拿本机账号去查别人的 pickcode。
if isPlaybackAPIPath(u.Path) && !s.isLocalPlaybackTarget(ctx, raw) {
switch strings.ToLower(strings.TrimSpace(u.Scheme)) {
case "http", "https":
return &StrmPlayResult{RedirectURL: raw}, nil
default:
return nil, fmt.Errorf("不支持的播放目标协议: %s", u.Scheme)
}
}
lowerPath := strings.ToLower(u.Path)
switch {
case strings.HasPrefix(lowerPath, "/api/strm/play/"):
@@ -156,6 +167,16 @@ func (s *StrmService) ResolvePlayTarget(ctx context.Context, raw string) (*StrmP
}
}
// isLocalPlaybackTarget 报告播放地址是否属于本机。这里没有 HTTP 请求上下文,
// 「本机」由 strm.base_url / 各同步目录覆盖的 base_url / 本机网盘账号共同界定
// (见 isInternalPlaybackTarget)。
func (s *StrmService) isLocalPlaybackTarget(ctx context.Context, raw string) bool {
if s == nil {
return true
}
return isInternalPlaybackTarget(ctx, s.repo, s.cfg, nil, raw)
}
// firstEnabledAccountOf 返回指定提供方第一个凭据可用的启用账号。
func (s *StrmService) firstEnabledAccountOf(ctx context.Context, provider string) (*model.StrmAccount, error) {
accounts, err := s.repo.StrmAccount.List(ctx)
+6 -2
View File
@@ -892,6 +892,10 @@ func truncateStringRuneSafe(s string, maxBytes int) string {
// cleanEntryName 清理单个目录名或文件名中的非法字符、控制字符、尾部点空格及 Windows 保留字,
// 确保在 Windows (NTFS/FAT)、Linux (ext4/btrfs/xfs) 及 NAS/SMB 挂载环境下均安全可用。
//
// 只裁剪**尾部**的点与空格:那是 Windows 明确禁止的部分。前导点是合法且常见的
// (隐藏目录/文件、.staging 之类),早先按 ". " 双向裁剪会把 `.media` 静默改写成
// `media`,导致刮削海报边车被写到去掉点后的平行目录、媒体目录反而拿不到图。
func cleanEntryName(name string, isDir bool) string {
name = strings.TrimSpace(name)
if name == "" {
@@ -900,7 +904,7 @@ func cleanEntryName(name string, isDir bool) string {
if isDir {
clean := sanitizeFilename(name)
clean = strings.Trim(clean, ". ")
clean = strings.TrimRight(clean, ". ")
if clean == "" {
return "unnamed"
}
@@ -921,7 +925,7 @@ func cleanEntryName(name string, isDir bool) string {
}
cleanBase := sanitizeFilename(base)
cleanBase = strings.Trim(cleanBase, ". ")
cleanBase = strings.TrimRight(cleanBase, ". ")
if cleanBase == "" {
cleanBase = "unnamed"
}
+14 -2
View File
@@ -746,7 +746,10 @@ func TestHandleMetaSha1Identity(t *testing.T) {
// fakeRemoteProvider 是 walkRemote 并发遍历的假提供方:返回一棵固定目录树,
// 并记录每个目录被 List 的次数,用于验证并发遍历无漏目录、无重复目录。
// walkRemote 会并发调用 List,因此计数必须加锁:此前直接写 map 会偶发
// "fatal error: concurrent map writes",把整个测试进程带走。
type fakeRemoteProvider struct {
mu sync.Mutex
listed map[string]int
}
@@ -756,11 +759,20 @@ func (f *fakeRemoteProvider) Resolve(context.Context, string) (*cloud.DirectLink
return &cloud.DirectLink{URL: "http://cdn/x.mkv"}, nil
}
// listedCount 返回某个目录被 List 的次数。
func (f *fakeRemoteProvider) listedCount(dirID string) int {
f.mu.Lock()
defer f.mu.Unlock()
return f.listed[dirID]
}
func (f *fakeRemoteProvider) List(_ context.Context, dirID string) ([]cloud.FileEntry, error) {
f.mu.Lock()
if f.listed == nil {
f.listed = map[string]int{}
}
f.listed[dirID]++
f.mu.Unlock()
switch dirID {
case "root":
return []cloud.FileEntry{
@@ -828,8 +840,8 @@ func TestWalkRemoteConcurrent(t *testing.T) {
st.flushPreferredVideos()
for _, dir := range []string{"root", "a", "a1", "b"} {
if provider.listed[dir] != 1 {
t.Errorf("目录 %s 被列出 %d 次,期望 1 次", dir, provider.listed[dir])
if count := provider.listedCount(dir); count != 1 {
t.Errorf("目录 %s 被列出 %d 次,期望 1 次", dir, count)
}
}
+38 -9
View File
@@ -410,14 +410,10 @@ func (s *SubtitleService) Serve(ctx context.Context, mediaID, sub string, w io.W
}
return s.serveEmbedded(ctx, m, index, w)
}
abs, err := filepath.Abs(sub)
abs, err := readExternalSubtitlePath(m, sub)
if err != nil {
return err
}
mediaDir, _ := filepath.Abs(filepath.Dir(m.Path))
if !pathWithin(abs, mediaDir) {
return fmt.Errorf("path escape")
}
f, err := os.Open(abs) // #nosec G304 -- abs is constrained to the media file directory with pathWithin.
if err != nil {
@@ -428,14 +424,17 @@ func (s *SubtitleService) Serve(ctx context.Context, mediaID, sub string, w io.W
if err != nil {
return err
}
// 非 UTF-8 的外挂字幕(UTF-16、GBK/Big5 等)必须先归一化:浏览器只能按
// UTF-8 解析 <track> 内容,否则整篇都会变成替换字符。
text := decodeSubtitleText(body)
switch strings.ToLower(filepath.Ext(abs)) {
case ".vtt":
_, err = w.Write(body)
_, err = io.WriteString(w, text)
case ".srt":
_, err = w.Write([]byte(srtToVTT(string(body))))
_, err = io.WriteString(w, srtToVTT(text))
case ".ass", ".ssa":
_, err = w.Write([]byte(assToVTT(string(body))))
_, err = io.WriteString(w, assToVTT(text))
default:
return errors.New("unsupported subtitle format")
}
@@ -517,12 +516,42 @@ func (s *SubtitleService) ServeASS(ctx context.Context, mediaID, sub string, w i
}
switch strings.ToLower(filepath.Ext(sub)) {
case ".ass", ".ssa":
return s.ServeRaw(ctx, mediaID, sub, w)
// 外挂 ASS 同样要归一化成 UTF-8:libass 只认 UTF-8,UTF-16/GBK 的
// 字幕交给它会解析不到任何事件,表现为「字幕选中了却不显示」。
abs, err := readExternalSubtitlePath(m, sub)
if err != nil {
return err
}
f, err := os.Open(abs) // #nosec G304 -- abs is constrained to the media file directory with pathWithin.
if err != nil {
return err
}
defer f.Close()
body, err := io.ReadAll(f)
if err != nil {
return err
}
_, err = io.WriteString(w, decodeSubtitleText(body))
return err
default:
return errors.New("subtitle is not ASS/SSA")
}
}
// readExternalSubtitlePath 校验外挂字幕路径(必须落在媒体文件所在目录内)并返回
// 绝对路径。Serve 与 ServeASS 共用,避免两处各自实现出现安全口径不一致。
func readExternalSubtitlePath(m *model.Media, sub string) (string, error) {
abs, err := filepath.Abs(sub)
if err != nil {
return "", err
}
mediaDir, _ := filepath.Abs(filepath.Dir(m.Path))
if !pathWithin(abs, mediaDir) {
return "", fmt.Errorf("path escape")
}
return abs, nil
}
func (s *SubtitleService) serveEmbeddedASS(ctx context.Context, media *model.Media, streamIndex int, w io.Writer) error {
input, err := s.resolveInput(ctx, media)
if err != nil {
+167
View File
@@ -0,0 +1,167 @@
package service
import (
"bytes"
"encoding/binary"
"math"
"regexp"
"strings"
"unicode"
"unicode/utf16"
"unicode/utf8"
"golang.org/x/text/encoding"
"golang.org/x/text/encoding/japanese"
"golang.org/x/text/encoding/korean"
"golang.org/x/text/encoding/simplifiedchinese"
"golang.org/x/text/encoding/traditionalchinese"
)
// 外挂字幕文件的编码归一化。
//
// 字幕文件由字幕组/工具生成,编码并不统一:除 UTF-8 外,中文圈常见 GBK/GB18030,
// 港台常见 Big5,部分 Windows 工具(Aegisub 早期版本、Subtitle Edit 的某些导出)
// 会写成带 BOM 的 UTF-16LE。浏览器 <track> 与 libass(JASSUB)都只按 UTF-8 解析,
// 后端如果原样下发字节,非 UTF-8 的字幕会整篇变成替换字符——表现就是「字幕轨已经
// 选中,但画面上一个字都不显示」。所以外挂字幕在服务端就要统一解码成 UTF-8。
//
// 注意:只用于下发给浏览器/播放器的文本路径。Emby 兼容层的 /subtitles 原始字节
// 接口(ServeRaw)必须保持字节一致,不能经过这里。
// 各编码的 BOM 前缀。
var (
utf8BOM = []byte{0xEF, 0xBB, 0xBF}
utf16BEBOM = []byte{0xFE, 0xFF}
utf16LEBOM = []byte{0xFF, 0xFE}
)
// subtitleLegacyEncodings 是无 BOM 时的候选编码,按优先级排列。
// GB18030 是 GBK/GB2312 的超集,中文外挂字幕绝大多数是它,因此排在最前——
// 多个候选都能解出合法字幕时,靠前的优先。
var subtitleLegacyEncodings = []struct {
name string
enc encoding.Encoding
}{
{"gb18030", simplifiedchinese.GB18030},
{"big5", traditionalchinese.Big5},
{"shift_jis", japanese.ShiftJIS},
{"euc-kr", korean.EUCKR},
}
// subtitleTimecodePattern 匹配 SRT(00:00:01,000 --> ...)与 WebVTT
// (00:00:01.000 --> ...)的时间行。
var subtitleTimecodePattern = regexp.MustCompile(`\d{1,2}:\d{2}:\d{2}[,.]\d{1,3}\s*-->`)
// decodeSubtitleText 把字幕文件字节归一化成 UTF-8 文本。识别不出编码时原样返回,
// 保证不会比「直接透传」更糟。
func decodeSubtitleText(raw []byte) string {
switch {
case bytes.HasPrefix(raw, utf8BOM):
return string(raw[len(utf8BOM):])
case bytes.HasPrefix(raw, utf16LEBOM):
return decodeUTF16(raw[len(utf16LEBOM):], false)
case bytes.HasPrefix(raw, utf16BEBOM):
return decodeUTF16(raw[len(utf16BEBOM):], true)
}
// 无 BOM 且是合法 UTF-8:这是绝大多数情况,直接返回。
if utf8.Valid(raw) {
return string(raw)
}
best := ""
bestScore := math.MinInt
consider := func(text string) {
if !utf8.ValidString(text) || !looksLikeSubtitle(text) {
return
}
score := subtitlePlausibility(text)
if score > bestScore {
best, bestScore = text, score
}
}
// 无 BOM 的 UTF-16 少见(多来自手工编辑),但一旦是它,所有单字节编码都会
// 解出带大量 NUL 的垃圾,因此先用「格式特征」筛掉这些候选。
consider(decodeUTF16(raw, false))
consider(decodeUTF16(raw, true))
for _, candidate := range subtitleLegacyEncodings {
text, err := candidate.enc.NewDecoder().Bytes(raw)
if err != nil {
continue
}
consider(string(text))
}
if best != "" {
return best
}
return string(raw)
}
// decodeUTF16 按给定字节序把 UTF-16 字节解码为字符串(含代理对)。
func decodeUTF16(raw []byte, bigEndian bool) string {
units := make([]uint16, 0, len(raw)/2)
for i := 0; i+1 < len(raw); i += 2 {
if bigEndian {
units = append(units, binary.BigEndian.Uint16(raw[i:i+2]))
} else {
units = append(units, binary.LittleEndian.Uint16(raw[i:i+2]))
}
}
return string(utf16.Decode(units))
}
// looksLikeSubtitle 判断解码结果是否真的像一份字幕。解码器「没报错」并不代表
// 解对了——很多编码能把任意字节解成合法文本,只有格式特征才能确认。
func looksLikeSubtitle(text string) bool {
head := text
if len(head) > 4096 {
head = head[:4096]
}
if strings.Contains(head, "[Script Info]") || strings.Contains(head, "Dialogue:") {
return true // ASS/SSA
}
if strings.HasPrefix(strings.TrimSpace(head), "WEBVTT") {
return true
}
return subtitleTimecodePattern.MatchString(head) // SRT / WebVTT 时间行
}
// subtitlePlausibility 给解码结果打分,返回 -100..100 的可信度:正常文本字符加分,
// 替换字符(解码失败)、控制字符、未分配码位扣分。
//
// 刻意用比例而不是字符数量:单字节回退会把一个双字节字符拆成两个字符,字符多的
// 候选看起来「更正常」,实际是解错了——GBK 中文字幕被当成 Shift_JIS 解,就会得到
// 一串半角片假名,数量比正确结果还多。半角片假名单独扣分正是为了压住这种情况。
func subtitlePlausibility(text string) int {
total := 0
weight := 0
for _, r := range text {
total++
switch {
case r == utf8.RuneError:
weight -= 8
case r == '\n' || r == '\r' || r == '\t':
// 正常换行不计分也不扣分。
case r < 0x20 || r == 0x7F:
weight -= 8
case unicode.Is(unicode.Co, r) || unicode.Is(unicode.Cn, r):
weight -= 4
case r >= 0xFF61 && r <= 0xFF9F:
// 半角片假名:GBK 汉字被按 Shift_JIS 解出来的典型产物。
weight -= 2
case unicode.In(r, unicode.Han, unicode.Hiragana, unicode.Katakana, unicode.Hangul),
unicode.Is(unicode.Latin, r), unicode.IsDigit(r), unicode.IsPunct(r):
weight++
case unicode.IsSpace(r):
// 空格(含全角空格)不参与打分。
default:
weight--
}
}
if total == 0 {
return 0
}
return weight * 100 / total
}
+169
View File
@@ -0,0 +1,169 @@
package service
import (
"bytes"
"encoding/binary"
"os"
"path/filepath"
"strings"
"testing"
"unicode/utf16"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"golang.org/x/text/encoding/simplifiedchinese"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
const assFixture = "[Script Info]\n" +
"Title:Railgun 01 BD\n" +
"ScriptType:v4.00+\n" +
"\n" +
"[Events]\n" +
"Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n" +
"Dialogue: 0,0:00:01.00,0:00:02.00,Default,,0,0,0,,放て!心に刻んだ夢を\n"
const srtFixture = "1\n" +
"00:00:01,000 --> 00:00:02,000\n" +
"只有我的超电磁炮\n" +
"\n"
func encodeUTF16(s string, bigEndian bool, withBOM bool) []byte {
units := utf16.Encode([]rune(s))
out := make([]byte, 0, len(units)*2+2)
if withBOM {
if bigEndian {
out = append(out, 0xFE, 0xFF)
} else {
out = append(out, 0xFF, 0xFE)
}
}
buf := make([]byte, 2)
for _, unit := range units {
if bigEndian {
binary.BigEndian.PutUint16(buf, unit)
} else {
binary.LittleEndian.PutUint16(buf, unit)
}
out = append(out, buf...)
}
return out
}
func encodeGBK(t *testing.T, s string) []byte {
t.Helper()
encoded, err := simplifiedchinese.GBK.NewEncoder().Bytes([]byte(s))
if err != nil {
t.Fatalf("encode GBK: %v", err)
}
return encoded
}
// 这是本次线上问题的核心场景:字幕组给的 ASS 是带 BOM 的 UTF-16LE,
// 之前原样下发,浏览器按 UTF-8 解开后整篇是替换字符,一个字都渲染不出来。
func TestDecodeSubtitleTextNormalisesLegacyEncodings(t *testing.T) {
cases := []struct {
name string
raw []byte
}{
{"utf8", []byte(assFixture)},
{"utf8-bom", append([]byte{0xEF, 0xBB, 0xBF}, []byte(assFixture)...)},
{"utf16le-bom", encodeUTF16(assFixture, false, true)},
{"utf16be-bom", encodeUTF16(assFixture, true, true)},
{"utf16le-no-bom", encodeUTF16(assFixture, false, false)},
{"gb18030", encodeGBK(t, assFixture)},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := decodeSubtitleText(tc.raw)
if !strings.Contains(got, "[Script Info]") {
t.Fatalf("decoded text lost ASS header: %q", got)
}
if !strings.Contains(got, "Dialogue:") {
t.Fatalf("decoded text lost Dialogue line: %q", got)
}
if !strings.Contains(got, "放て!心に刻んだ夢を") {
t.Fatalf("decoded text lost non-ASCII content: %q", got)
}
if strings.ContainsRune(got, 0) || strings.ContainsRune(got, '\uFFFD') {
t.Fatalf("decoded text still contains NUL or replacement runes: %q", got)
}
})
}
}
func TestDecodeSubtitleTextKeepsUnknownBytes(t *testing.T) {
raw := []byte("not a subtitle at all: \x81\x82\x83\x84")
if got := decodeSubtitleText(raw); got != string(raw) {
t.Fatalf("decodeSubtitleText mangled non-subtitle bytes: %q", got)
}
}
// 接口层回归:外挂 UTF-16 字幕经 /subtitles/:id/ass(libass 路径)与
// /subtitles/:id(WebVTT 降级路径)下发时都必须是可解析的 UTF-8。
func TestSubtitleHandlersNormaliseUTF16Sidecar(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:subtitle-encoding?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Library{}, &model.Media{}); err != nil {
t.Fatal(err)
}
dir := t.TempDir()
videoPath := filepath.Join(dir, "Railgun S01E01.mkv")
assPath := filepath.Join(dir, "Railgun S01E01.ass")
srtPath := filepath.Join(dir, "Railgun S01E01.zh.srt")
if err := os.WriteFile(videoPath, []byte("video"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(assPath, encodeUTF16(assFixture, false, true), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(srtPath, encodeGBK(t, srtFixture), 0o644); err != nil {
t.Fatal(err)
}
media := model.Media{Title: "Railgun", Path: videoPath}
if err := db.Create(&media).Error; err != nil {
t.Fatal(err)
}
svc := NewSubtitleService(&config.Config{}, zap.NewNop(), repository.New(db))
var buf bytes.Buffer
if err := svc.ServeASS(t.Context(), media.ID, assPath, &buf); err != nil {
t.Fatalf("ServeASS: %v", err)
}
ass := buf.String()
if !bytes.HasPrefix(buf.Bytes(), []byte("[Script Info]")) {
t.Fatalf("ServeASS output is not UTF-8 ASS: %q", ass)
}
if !strings.Contains(ass, "放て!心に刻んだ夢を") {
t.Fatalf("ServeASS output lost dialogue text: %q", ass)
}
buf.Reset()
if err := svc.Serve(t.Context(), media.ID, assPath, &buf); err != nil {
t.Fatalf("Serve(ass): %v", err)
}
vtt := buf.String()
if !strings.HasPrefix(vtt, "WEBVTT") || !strings.Contains(vtt, "-->") {
t.Fatalf("ASS->WebVTT fallback produced no cues: %q", vtt)
}
if !strings.Contains(vtt, "放て!心に刻んだ夢を") {
t.Fatalf("ASS->WebVTT fallback lost dialogue text: %q", vtt)
}
buf.Reset()
if err := svc.Serve(t.Context(), media.ID, srtPath, &buf); err != nil {
t.Fatalf("Serve(srt): %v", err)
}
vtt = buf.String()
if !strings.Contains(vtt, "只有我的超电磁炮") {
t.Fatalf("GBK SRT was not transcoded: %q", vtt)
}
}
+25 -3
View File
@@ -1,13 +1,24 @@
package service
import (
"reflect"
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/truewhile/MeBox/internal/model"
)
// newServiceTestDB opens an in-memory DB for service tests.
//
// The full model set is always migrated, on top of any explicitly requested
// models. Service code probes tables that a given test may not care about
// (settings for adult visibility, play_profiles for playback, media for library
// counts); with a partial schema those probes fail with "no such table" and the
// default GORM logger floods the test output, burying real failures. Migrating
// everything keeps fixtures faithful to production and the output quiet.
func newServiceTestDB(t *testing.T, models ...any) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
@@ -17,10 +28,21 @@ func newServiceTestDB(t *testing.T, models ...any) *gorm.DB {
if sqlDB, err := db.DB(); err == nil {
t.Cleanup(func() { _ = sqlDB.Close() })
}
if len(models) > 0 {
if err := db.AutoMigrate(models...); err != nil {
t.Fatal(err)
toMigrate := model.AllModels()
seen := make(map[string]struct{}, len(toMigrate)+len(models))
for _, m := range toMigrate {
seen[reflect.TypeOf(m).String()] = struct{}{}
}
for _, m := range models {
key := reflect.TypeOf(m).String()
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
toMigrate = append(toMigrate, m)
}
if err := db.AutoMigrate(toMigrate...); err != nil {
t.Fatal(err)
}
return db
}
+36
View File
@@ -54,6 +54,11 @@ type TranscoderService struct {
startGates sync.Map // mediaID -> *sync.Mutex
strmResolve func(ctx context.Context, raw string) (*StrmPlayResult, error)
probe *FFprobeService
// availMu guards the cached ffmpeg availability used by Available().
availMu sync.Mutex
availCheckedAt time.Time
availValue bool
}
// hlsJob holds the live state of one ffmpeg run.
@@ -99,6 +104,37 @@ func NewTranscoderService(cfg *config.Config, log *zap.Logger, repo *repository.
}
}
// transcodeAvailabilityTTL bounds how long an Available() result is reused.
// Validating ffmpeg actually spawns it, and /playback is polled on every player
// load, so the answer is cached briefly. It stays short so a freshly installed
// ffmpeg becomes usable without restarting the server.
const transcodeAvailabilityTTL = 20 * time.Second
// Available reports whether HLS transcoding can run right now: transcoding must
// be enabled by configuration and a usable ffmpeg must resolve. Callers use it to
// stop advertising local HLS renditions that would only fail with a 500.
func (t *TranscoderService) Available() bool {
if t == nil || t.cfg == nil {
return false
}
if !t.cfg.Transcoder.Enabled {
return false
}
t.availMu.Lock()
defer t.availMu.Unlock()
if !t.availCheckedAt.IsZero() && time.Since(t.availCheckedAt) < transcodeAvailabilityTTL {
return t.availValue
}
_, err := t.resolveFFmpegPath()
available := err == nil
if !available && t.log != nil {
t.log.Debug("local HLS renditions unavailable", zap.Error(err))
}
t.availValue = available
t.availCheckedAt = time.Now()
return available
}
// HLSDir is the per-media directory that holds index.m3u8 + segment files.
func (t *TranscoderService) HLSDir(mediaID string) string {
return filepath.Join(t.cfg.Cache.CacheDir, "hls", mediaID)
@@ -0,0 +1,67 @@
package service
import (
"path/filepath"
"testing"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
)
// Available() gates the local HLS renditions advertised to the player, so it must
// be false both when transcoding is disabled by configuration and when no usable
// ffmpeg can be resolved.
func TestTranscoderAvailableGatesOnConfigAndBinary(t *testing.T) {
cases := []struct {
name string
enabled bool
ffmpegPath string
}{
{name: "disabled by config", enabled: false, ffmpegPath: filepath.Join(t.TempDir(), "missing-ffmpeg")},
{name: "no ffmpeg binary", enabled: true, ffmpegPath: filepath.Join(t.TempDir(), "missing-ffmpeg")},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cfg := &config.Config{}
cfg.Transcoder.Enabled = tc.enabled
cfg.App.FFmpegPath = tc.ffmpegPath
svc := NewTranscoderService(cfg, zap.NewNop(), nil, NewHub(zap.NewNop()))
if svc.Available() {
t.Fatalf("Available() = true, want false (enabled=%v path=%q)", tc.enabled, tc.ffmpegPath)
}
})
}
}
// A nil service or nil config must not panic: playback info is built on paths
// where wiring may be partial.
func TestTranscoderAvailableNilSafe(t *testing.T) {
var nilSvc *TranscoderService
if nilSvc.Available() {
t.Fatal("nil transcoder should report unavailable")
}
svc := &TranscoderService{}
if svc.Available() {
t.Fatal("transcoder without config should report unavailable")
}
}
// Playback info must not advertise local HLS renditions when the playback service
// has no transcoder wired in.
func TestCloud115PlaybackServiceWithoutTranscoderMarksLocalQualitiesUnavailable(t *testing.T) {
svc := NewCloud115PlaybackService(&config.Config{}, zap.NewNop(), nil, nil)
if svc.localTranscodeAvailable() {
t.Fatal("playback service without a transcoder must report local HLS unavailable")
}
options := LocalQualityOptions(nil, svc.localTranscodeAvailable())
if len(options) == 0 {
t.Fatal("local quality presets should still be listed for the UI")
}
for _, option := range options {
if option.Available {
t.Fatalf("quality %s advertised as available without a transcoder", option.ID)
}
}
}
+11 -3
View File
@@ -14,14 +14,22 @@ type walkInfo struct {
}
// walk traverses root depth-first calling fn for every entry. Hidden
// directories (starting with ".") are skipped.
// directories (starting with ".") below the root are skipped.
//
// The root itself is deliberately exempt from the hidden-directory rule.
// WalkDir reports the root with its own base name, so a root such as
// "/media/.staging" used to match the hidden check and yield filepath.SkipDir
// immediately — the entire tree was skipped silently, with no error and no
// warning. A library or organize source may legitimately live in a
// dot-prefixed directory, so only children are filtered.
func walk(root string, fn func(string, walkInfo) error) error {
return filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
rootPath := filepath.Clean(root)
return filepath.WalkDir(rootPath, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil // best effort — keep walking
}
name := d.Name()
if d.IsDir() && name != "." && len(name) > 1 && name[0] == '.' {
if d.IsDir() && path != rootPath && name != "." && len(name) > 1 && name[0] == '.' {
return filepath.SkipDir
}
info := walkInfo{isDir: d.IsDir()}
+115
View File
@@ -0,0 +1,115 @@
package service
import (
"os"
"path/filepath"
"testing"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
)
func writeWalkFile(t *testing.T, path string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir %s: %v", filepath.Dir(path), err)
}
if err := os.WriteFile(path, []byte("data"), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
func collectWalkPaths(t *testing.T, root string) []string {
t.Helper()
var seen []string
if err := walk(root, func(path string, info walkInfo) error {
if !info.isDir {
seen = append(seen, path)
}
return nil
}); err != nil {
t.Fatalf("walk %s: %v", root, err)
}
return seen
}
// A dot-prefixed walk root must not be treated as a hidden directory: doing so
// skipped the whole tree silently, so a library rooted at e.g. "/media/.staging"
// indexed nothing and the organizer reported "0 organized" with no error.
func TestWalkIncludesDotPrefixedRoot(t *testing.T) {
parent := t.TempDir()
dotRoot := filepath.Join(parent, ".dotroot")
want := filepath.Join(dotRoot, "Movie (2021)", "Movie (2021).mkv")
writeWalkFile(t, want)
got := collectWalkPaths(t, dotRoot)
if len(got) != 1 || filepath.Clean(got[0]) != filepath.Clean(want) {
t.Fatalf("walk over dot-prefixed root = %#v, want the one file inside it", got)
}
}
// Hidden directories *below* the root are still skipped.
func TestWalkStillSkipsHiddenChildDirectories(t *testing.T) {
root := t.TempDir()
visible := filepath.Join(root, "Movie (2021)", "Movie (2021).mkv")
hidden := filepath.Join(root, ".trash", "Old Movie.mkv")
deepHidden := filepath.Join(root, "Season 01", ".thumbnails", "thumb.mkv")
writeWalkFile(t, visible)
writeWalkFile(t, hidden)
writeWalkFile(t, deepHidden)
got := collectWalkPaths(t, root)
if len(got) != 1 || filepath.Clean(got[0]) != filepath.Clean(visible) {
t.Fatalf("walk = %#v, want only the visible file", got)
}
}
// End-to-end: a library whose root directory starts with "." must be scanned.
func TestScanLibraryIndexesDotPrefixedRoot(t *testing.T) {
sc, repos := newScannerTestEnv(t)
root := filepath.Join(t.TempDir(), ".media")
lib := model.Library{Name: "Movies", Path: root, Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
writeWalkFile(t, filepath.Join(root, "Movie (2021)", "Movie (2021).mkv"))
res, err := sc.ScanLibrary(t.Context(), lib.ID)
if err != nil {
t.Fatalf("scan: %v", err)
}
if res.Added != 1 {
t.Fatalf("scan added=%d (errors=%v), want 1 for a dot-prefixed library root", res.Added, res.Errors)
}
if got := countMedia(t, repos); got != 1 {
t.Fatalf("media count = %d, want 1", got)
}
}
// End-to-end: organizing from a dot-prefixed source directory must find files.
func TestOrganizeDirectoryReadsDotPrefixedSource(t *testing.T) {
repos := newOrganizerTestRepo(t)
cfg := &config.Config{}
cfg.Organizer.SmartClassify = true
organizer := NewOrganizerService(cfg, zap.NewNop(), repos)
root := t.TempDir()
src := filepath.Join(root, ".downloads")
dest := filepath.Join(root, "media")
writeWalkFile(t, filepath.Join(src, "Oppenheimer.2023.2160p.WEB-DL.H265", "oppenheimer-2160p.mkv"))
res, err := organizer.OrganizeDirectory(t.Context(), OrganizeOptions{
SourcePath: src,
DestPath: dest,
TransferMode: TransferCopy,
})
if err != nil {
t.Fatalf("organize directory: %v", err)
}
if res.Organized != 1 {
t.Fatalf("organized=%d skipped=%d errors=%v, want the file under a dot-prefixed source to be organized",
res.Organized, res.Skipped, res.Errors)
}
}
+74 -1
View File
@@ -1,8 +1,22 @@
const ARTWORK_CACHE_PREFIX = 'mebox-artwork-'
const ARTWORK_CACHE = `${ARTWORK_CACHE_PREFIX}v2`
const ARTWORK_CACHE = `${ARTWORK_CACHE_PREFIX}v3`
const MIN_CACHEABLE_ARTWORK_BYTES = 128
const STRIP_QUERY_KEYS = ['token', 'profile_id', 'profile_pin_token']
// 浏览器侧作品缓存的容量上限。
//
// 没有上限时 Cache Storage 会一直增长,浏览器在存储压力下会整体清空该
// origin 的缓存,失效时机完全不可控。条数上限每次写入都检查(只是读一遍
// 键,开销小);字节上限最多每两分钟统计一次,因为需要逐个读取已存响应的
// Content-Length。两者都淘汰到上限的 80%,避免刚清完又立刻触发。
const MAX_CACHE_ENTRIES = 500
const MAX_CACHE_BYTES = 64 * 1024 * 1024
const TRIM_TARGET_RATIO = 0.8
const BYTE_TRIM_INTERVAL_MS = 2 * 60 * 1000
let lastByteTrimAt = 0
let trimInFlight = null
function isArtworkRequest(url) {
if (url.origin !== self.location.origin) return false
if (url.pathname === '/api/img') return true
@@ -54,10 +68,69 @@ async function cacheArtwork(request) {
if (cacheResponse) {
await cache.put(cacheKey, cacheResponse)
await deleteOldArtworkVariants(cache, cacheKey)
await trimArtworkCache(cache)
}
return response
}
// trimArtworkCache 把缓存裁剪回容量上限之内,按写入顺序淘汰(最旧的先删)。
// 同一时刻只允许一次裁剪在跑。
async function trimArtworkCache(cache) {
if (trimInFlight) return trimInFlight
trimInFlight = runArtworkTrim(cache).catch(() => undefined)
try {
await trimInFlight
} finally {
trimInFlight = null
}
}
async function runArtworkTrim(cache) {
const keys = await cache.keys()
if (keys.length > MAX_CACHE_ENTRIES) {
const keep = Math.floor(MAX_CACHE_ENTRIES * TRIM_TARGET_RATIO)
await deleteOldestArtwork(cache, keys, keys.length - keep)
return
}
const now = Date.now()
if (now - lastByteTrimAt < BYTE_TRIM_INTERVAL_MS) return
lastByteTrimAt = now
const sizes = []
let total = 0
for (const key of keys) {
const response = await cache.match(key)
const size = cacheableResponseSize(response)
sizes.push(size)
total += size
}
if (total <= MAX_CACHE_BYTES) return
const target = Math.floor(MAX_CACHE_BYTES * TRIM_TARGET_RATIO)
let remaining = total
const evicted = []
for (let i = 0; i < keys.length && remaining > target; i += 1) {
remaining -= sizes[i]
evicted.push(keys[i])
}
await Promise.all(evicted.map((key) => cache.delete(key)))
}
// cacheableResponseSize 用 Content-Length 估算体积。MeBox 的图片响应由
// ServeContent 生成,始终带该头;缺失时按 0 计(只是少算,不会误删)。
function cacheableResponseSize(response) {
if (!response) return 0
const size = Number(response.headers.get('Content-Length') || '0')
return Number.isFinite(size) && size > 0 ? size : 0
}
// deleteOldestArtwork 按 cache.keys() 的顺序(写入顺序)删除最旧的若干条。
async function deleteOldestArtwork(cache, keys, count) {
const victims = keys.slice(0, Math.max(0, count))
await Promise.all(victims.map((key) => cache.delete(key)))
}
async function cloneCacheableArtworkResponse(response) {
if (!response.ok) return null
const contentType = response.headers.get('Content-Type') || ''
+21
View File
@@ -230,6 +230,27 @@ export type ImageURLOptions =
quality?: number
}
// ARTWORK is the shared set of thumbnail sizes the whole app requests.
//
// The server caches thumbnails per (source file, maxWidth, maxHeight, quality):
// every extra combination is another full decode of the original image (measured
// at 60-120ms for a 1920x1080 backdrop, up to ~800ms for a large poster) plus
// another cache file, so do not invent sizes at the call site — pick a preset.
// Keeping this list short is what stops a single page from triggering a dozen
// different decodes for the same artwork.
export const ARTWORK = {
/** 最小一档(160px):卡片的模糊占位图,以及 30-40px 级别的列表缩略图。 */
posterTiny: { maxWidth: 160, quality: 60 },
/** 海报列表与卡片(2:3 容器,紧凑布局共用同一份缓存)。 */
posterCard: { maxWidth: 480, maxHeight: 600, quality: 80 },
/** 海报详情页、剧集详情页头部与首页大图。 */
posterDetail: { maxWidth: 560, maxHeight: 840, quality: 80 },
/** 剧照大图:首页 hero、影片详情页背景。 */
backdropHero: { maxWidth: 1920, maxHeight: 1080, quality: 80 },
/** 剧照小图:媒体库封面、剧集条目、搜索结果等。 */
backdropStrip: { maxWidth: 480, maxHeight: 320, quality: 80 },
}
export function imageURL(remote?: string, version?: string, options: ImageURLOptions = false): string {
if (!remote) return ''
const versionQuery = version ? `v=${encodeURIComponent(version)}` : ''
+4
View File
@@ -72,6 +72,8 @@ export interface DanmakuConfig {
area: string
volume: number
playback_rate: number
/** 当前用户是否已经看过 VR 全景播放的首次操作说明(按用户存储)。 */
vr360_guide_seen: boolean
/** Per-user preference: merge the same episode's multiple sources. */
merge_sources: boolean
}
@@ -89,6 +91,8 @@ export interface DanmakuSettingsPatch {
merge_sources?: boolean
volume?: number
playback_rate?: number
/** 看过 VR 操作说明后置为 true,之后不再弹出。 */
vr360_guide_seen?: boolean
}
// danmakuAPI fetches danmaku comments for a media item. The backend resolves
+1
View File
@@ -89,6 +89,7 @@ export function Layout() {
)}
<LayoutWorkspace
routeKey={location.pathname}
scrollKey={`${location.pathname}${location.search}`}
userKey={user?.id}
showMobileBottomNav={showMobileBottomNav}
/>
+2 -2
View File
@@ -3,7 +3,7 @@ import { createPortal } from 'react-dom'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { ArrowLeft, Film, LoaderCircle, Menu, Search, Star, X } from 'lucide-react'
import { imageURL } from '../api/client'
import { ARTWORK, imageURL } from '../api/client'
import { mediaAPI } from '../api/library'
import type { Media, PlayProfile, User } from '../types'
import { favouriteMediaLink } from '../utils/mediaNavigation'
@@ -368,7 +368,7 @@ function LayoutHeaderSearch() {
<div className="relative h-12 w-9 shrink-0 overflow-hidden rounded-lg bg-[var(--app-panel-soft)]">
{item.poster_url ? (
<img
src={imageURL(item.poster_url, item.updated_at)}
src={imageURL(item.poster_url, item.updated_at, ARTWORK.posterCard)}
alt=""
className="h-full w-full object-cover"
loading="lazy"
+4 -1
View File
@@ -31,6 +31,8 @@ type LayoutSidebarsProps = Omit<
type LayoutWorkspaceProps = {
routeKey: string
/** 滚动记忆的 key:含 query,让同一路径下的不同视图(如剧集面板)各记一份位置。 */
scrollKey?: string
userKey?: string
showMobileBottomNav?: boolean
}
@@ -113,10 +115,11 @@ export function LayoutSidebars({
export function LayoutWorkspace({
routeKey,
scrollKey,
userKey = 'anonymous',
showMobileBottomNav = false,
}: LayoutWorkspaceProps) {
useScrollMemory(routeKey, userKey)
useScrollMemory(scrollKey ?? routeKey, userKey)
const bottomPad = showMobileBottomNav
? 'pb-[calc(3.75rem+env(safe-area-inset-bottom,0px))] lg:pb-10'
@@ -1,6 +1,6 @@
import { Check, LoaderCircle, Search, Sparkles, X } from 'lucide-react'
import { imageURL } from '../api/client'
import { ARTWORK, imageURL } from '../api/client'
import type { ManualScrapeCandidate } from '../api/library'
import {
candidateIDText,
@@ -202,7 +202,7 @@ function ManualScrapeCandidateRow({
<div className="flex min-w-0 max-w-full flex-col gap-4 overflow-hidden rounded-xl border border-sand-200 bg-white p-3 shadow-sm sm:flex-row">
<div className="h-28 w-20 shrink-0 overflow-hidden rounded-lg bg-sand-100">
{item.poster_url ? (
<img src={imageURL(item.poster_url)} alt={item.title} loading="lazy" decoding="async" className="h-full w-full object-cover" referrerPolicy="no-referrer" />
<img src={imageURL(item.poster_url, undefined, ARTWORK.posterCard)} alt={item.title} loading="lazy" decoding="async" className="h-full w-full object-cover" referrerPolicy="no-referrer" />
) : (
<div className="flex h-full items-center justify-center text-xs text-sand-500">无海报</div>
)}
+24 -24
View File
@@ -1,7 +1,7 @@
import { memo, useEffect, useRef, useState, type ReactNode } from 'react'
import { Link } from 'react-router-dom'
import { Film, Play, Layers, Star } from 'lucide-react'
import { imageURL } from '../api/client'
import { ARTWORK, imageURL } from '../api/client'
import type { Media } from '../types'
const ACTION_OVERLAY_CLASS =
@@ -10,13 +10,14 @@ const ACTION_OVERLAY_CLASS =
// memo:父级状态变化(如轮播切图、其它卡片操作)不再级联重渲染所有卡片。
// 注意 actions/renderActions 必须引用稳定(用 renderActions 传函数)memo 才生效。
export const MediaCard = memo(function MediaCard({
media, progress, count, rating, linkTo, onClick, actions, renderActions, compact,
media, progress, count, rating, linkTo, linkState, onClick, actions, renderActions, compact,
}: {
media: Media
progress?: number
count?: number
rating?: number
linkTo?: string
linkState?: { from?: string }
onClick?: () => void
actions?: ReactNode
renderActions?: (media: Media) => ReactNode
@@ -25,12 +26,9 @@ export const MediaCard = memo(function MediaCard({
const ref = useRef<HTMLDivElement>(null)
const href = linkTo ?? `/media/${media.id}`
const [posterFit, setPosterFit] = useState<'cover' | 'contain'>('cover')
const posterSrc = imageURL(media.poster_url, media.updated_at, {
maxWidth: compact ? 320 : 480,
maxHeight: compact ? 480 : 600,
quality: 82,
})
const blurredPosterSrc = imageURL(media.poster_url, media.updated_at, { maxWidth: 160, quality: 60 })
// 固定档位而不是在调用点拼尺寸:紧凑布局与普通卡片共用同一份缩略图缓存。
const posterSrc = imageURL(media.poster_url, media.updated_at, ARTWORK.posterCard)
const blurredPosterSrc = imageURL(media.poster_url, media.updated_at, ARTWORK.posterTiny)
const displayRating = rating ?? media.rating
const versionCount = media.versions?.length ?? 0
// renderActions 延迟到卡片自身渲染时才调用,保证 memo 生效
@@ -82,14 +80,6 @@ export const MediaCard = memo(function MediaCard({
</div>
)}
{/* Episode count badge:统一放右下角(桌面端与移动端一致),与左上角评分彻底错开 */}
{(count !== undefined && count > 1) || (count === undefined && versionCount > 1) ? (
<span className="absolute bottom-2 right-2 inline-flex items-center gap-1 rounded-lg border border-white/15 bg-[#111827]/90 px-1.5 py-0.5 text-[10px] font-bold text-white shadow-sm">
<Layers size={10} className="shrink-0 text-[#c9954a]" />
<span>{count !== undefined && count > 1 ? `${count} 集` : `${versionCount} 版本`}</span>
</span>
) : null}
{/* Rating Badge */}
{displayRating > 0 && (
<span className="absolute left-2 top-2 inline-flex items-center gap-0.5 rounded-lg border border-white/15 bg-[#111827]/90 px-1.5 py-0.5 text-[10px] font-bold text-[#c9954a] shadow-sm">
@@ -137,13 +127,23 @@ export const MediaCard = memo(function MediaCard({
}`}>
{media.title}
</p>
<div className="flex items-center justify-between text-[11px] font-bold uppercase tracking-wider text-[var(--app-muted)]">
<div className="flex items-center justify-between gap-2 text-[11px] font-bold uppercase tracking-wider text-[var(--app-muted)]">
<span>{media.year > 0 ? media.year : "未知年份"}</span>
{media.video_codec && (
<span className="rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-1.5 py-0.5 text-[var(--app-subtle)]">
{media.video_codec}
</span>
)}
<span className="flex items-center gap-1.5">
{/* 集数/版本数徽标:放在海报下方的信息行而不是压在画面上。海报底部通常
自带片名,角标会盖住它(Game of Thrones / Breaking Bad 都很明显)。 */}
{(count !== undefined && count > 1) || (count === undefined && versionCount > 1) ? (
<span className="inline-flex items-center gap-1 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-1.5 py-0.5 text-[var(--app-subtle)]">
<Layers size={10} className="shrink-0 text-[#c9954a]" />
<span>{count !== undefined && count > 1 ? `${count} 集` : `${versionCount} 版本`}</span>
</span>
) : null}
{media.video_codec && (
<span className="rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-1.5 py-0.5 text-[var(--app-subtle)]">
{media.video_codec}
</span>
)}
</span>
</div>
</div>
</div>
@@ -167,7 +167,7 @@ export const MediaCard = memo(function MediaCard({
if (actionContent) {
return (
<div className="group relative block">
<Link to={href} className="block">
<Link to={href} state={linkState} className="block">
{card}
</Link>
<div className={ACTION_OVERLAY_CLASS}>
@@ -178,7 +178,7 @@ export const MediaCard = memo(function MediaCard({
}
return (
<Link to={href} className="group block">
<Link to={href} state={linkState} className="group block">
{card}
</Link>
)
+23 -48
View File
@@ -6,26 +6,23 @@ import { isStrmMedia, mediaVersionLabel, mediaVersionsOf } from '../utils/mediaV
type MediaVersionSwitcherProps = {
media: Media
/** 详情页:用 Link 跳转播放;播放页:回调切换 */
mode?: 'detail' | 'player'
onSelect?: (version: Media) => void
className?: string
}
export function MediaVersionSwitcher({
media,
mode = 'detail',
onSelect,
className = '',
}: MediaVersionSwitcherProps) {
/**
* 详情页的版本切换。
*
* 播放器内不再单独浮出一块版本条:多版本切换统一收在「选集」面板里
* (见 PlayerPlaylistPanel),这里只负责详情页的同片多版本跳转。
*/
export function MediaVersionSwitcher({ media, className = '' }: MediaVersionSwitcherProps) {
const versions = mediaVersionsOf(media)
if (versions.length <= 1) return null
const dark = mode === 'player'
return (
<div className={`space-y-2 ${className}`.trim()}>
<div className={`flex items-center gap-2 text-sm font-semibold ${dark ? 'text-white/90' : 'text-ink-600'}`}>
<Layers size={14} className={dark ? 'text-white/60' : 'text-sand-500'} />
<div className="flex items-center gap-2 text-sm font-semibold text-ink-600">
<Layers size={14} className="text-sand-500" />
<span>版本({versions.length})</span>
</div>
<div className="flex flex-wrap gap-2">
@@ -33,42 +30,10 @@ export function MediaVersionSwitcher({
const active = version.id === media.id
const isStrm = isStrmMedia(version)
const label = mediaVersionLabel(version)
const btnClass = dark
? active
? 'border-white/50 bg-white/20 text-white shadow-sm ring-1 ring-white/20'
: 'border-white/15 bg-white/5 text-white/80 hover:bg-white/15 hover:text-white'
: active
? 'border-brand-500/40 bg-brand-50 text-[#b07d35] font-bold shadow-sm'
: 'border-gray-200 bg-white text-ink-100 hover:border-brand-500/30 hover:bg-brand-50/40'
const btnClass = active
? 'border-brand-500/40 bg-brand-50 text-[#b07d35] font-bold shadow-sm'
: 'border-gray-200 bg-white text-ink-100 hover:border-brand-500/30 hover:bg-brand-50/40'
const content = (
<span className="inline-flex items-center gap-1.5">
{isStrm && (
<Cloud
size={12}
className={dark ? (active ? 'text-brand-300' : 'text-white/60') : (active ? 'text-brand-500' : 'text-sand-400')}
aria-label="云端 / STRM"
/>
)}
<span>{label}</span>
{!dark && active ? <span className="opacity-75">· 当前</span> : null}
</span>
)
if (mode === 'player') {
return (
<button
key={version.id}
type="button"
disabled={active}
onClick={() => onSelect?.(version)}
className={`rounded-xl border px-3 py-1.5 text-xs font-semibold transition disabled:cursor-default ${btnClass}`}
title={version.path || version.strm_url}
>
{content}
</button>
)
}
return (
<Link
key={version.id}
@@ -77,7 +42,17 @@ export function MediaVersionSwitcher({
className={`rounded-xl border px-3 py-1.5 text-xs font-semibold transition ${btnClass}`}
title={version.path || version.strm_url}
>
{content}
<span className="inline-flex items-center gap-1.5">
{isStrm && (
<Cloud
size={12}
className={active ? 'text-brand-500' : 'text-sand-400'}
aria-label="云端 / STRM"
/>
)}
<span>{label}</span>
{active ? <span className="opacity-75">· 当前</span> : null}
</span>
</Link>
)
})}
File diff suppressed because it is too large Load Diff
+27 -10
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Film, Hash, KeyRound, Loader2, MessageSquareText, RefreshCw, Search, Server, Settings2, Sparkles, Tag, X } from 'lucide-react'
import type { DanmakuAnime, DanmakuEpisode, DanmakuLoadedInfo } from '../api/danmaku'
import { PLAYER_DRAWER, PLAYER_ICON_BUTTON, PLAYER_PANEL_HEADER, PLAYER_SHEET, PLAYER_SHEET_BODY, PLAYER_SHEET_HEADER } from './playerTheme'
// PlayerDanmakuPanel — the on-player danmaku control panel. It displays
// the matched danmaku details (anime title, episode title, comment count,
@@ -55,6 +56,11 @@ type PlayerDanmakuPanelProps = {
danmakuInfo?: DanmakuLoadedInfo | null
onSelectEpisode: (episodeId: number, animeTitle: string, episodeTitle: string) => void
onResetAuto: () => void
/**
* 竖屏剧场模式:弹幕面板改为视频区底部的动作面板,而不是右侧抽屉,
* 窄屏下搜索框与滑杆不再被压扁。
*/
theater?: boolean
}
export function PlayerDanmakuPanel({
@@ -89,6 +95,7 @@ export function PlayerDanmakuPanel({
danmakuInfo,
onSelectEpisode,
onResetAuto,
theater = false,
}: PlayerDanmakuPanelProps) {
const [draft, setDraft] = useState(search)
const [advancedOpen, setAdvancedOpen] = useState(false)
@@ -194,27 +201,36 @@ export function PlayerDanmakuPanel({
return (
// 面板悬浮于视频上方:阻止点击冒泡,避免触发视频区域的播放/暂停切换。
// 排布和选集抽屉完全一致(整条贴住右边缘、标题栏常驻),两个面板互斥打开时
// 位置不会跳,用户也不用重新找入口。
<div
onClick={(e) => e.stopPropagation()}
className="absolute inset-x-3 top-12 bottom-3 z-30 w-auto overflow-y-auto overscroll-contain rounded-2xl border border-white/15 bg-black/85 p-4 text-white shadow-2xl backdrop-blur-md sm:inset-x-auto sm:bottom-auto sm:right-4 sm:top-16 sm:w-80 sm:max-h-[calc(100%-5rem)]"
onPointerDown={(e) => e.stopPropagation()}
onPointerUp={(e) => e.stopPropagation()}
className={
theater ? PLAYER_SHEET : `absolute inset-y-0 right-0 z-30 ${PLAYER_DRAWER}`
}
>
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2 text-sm font-semibold">
<MessageSquareText size={16} className="text-rose-400" /> 弹幕设置
{settingsSaving && <Loader2 size={12} className="animate-spin text-rose-300" />}
<div className={theater ? PLAYER_SHEET_HEADER : PLAYER_PANEL_HEADER}>
<div className="flex min-w-0 items-center gap-2 text-sm font-semibold">
<MessageSquareText size={16} className="shrink-0 text-rose-400" />
<span className="truncate">弹幕设置</span>
{settingsSaving && <Loader2 size={12} className="shrink-0 animate-spin text-rose-300" />}
</div>
<button
onClick={onClose}
className="rounded-full p-1 text-white/60 transition hover:bg-white/10 hover:text-white"
title="关闭"
className={`${PLAYER_ICON_BUTTON} ${theater ? 'h-10 w-10' : ''}`}
title="关闭 (Esc)"
>
<X size={16} />
<X size={theater ? 20 : 16} />
</button>
</div>
{/* 是否加载弹幕 */}
<div className={theater ? PLAYER_SHEET_BODY : 'min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-3.5'}>
{/* 弹幕总开关。从操作栏的「弹」按钮直接进来就到了这里,所以这一项就是
用户找的「开关弹幕」;文案跟旧的按钮提示保持一致,避免换个说法让人找不到。 */}
<label className="mb-3 flex cursor-pointer items-center justify-between rounded-lg bg-white/5 px-2.5 py-2 text-sm transition hover:bg-white/10">
<span className="text-white/85">加载弹幕</span>
<span className="text-white/85">显示弹幕</span>
<input
type="checkbox"
checked={enabled}
@@ -573,6 +589,7 @@ export function PlayerDanmakuPanel({
</div>
)}
</div>
</div>
</div>
)
}
@@ -0,0 +1,199 @@
import { useEffect, useRef } from 'react'
import type { ReactNode } from 'react'
import { ChevronDown, Layers, ListVideo } from 'lucide-react'
import type { Media } from '../types'
import { mediaVersionLabel, mediaVersionMatches } from '../utils/mediaVersion'
// PlayerMobileTheaterInfo — 竖屏剧场模式下视频下方的可滚动内容区。
//
// 竖屏手机不再用全屏居中黑边布局:视频贴顶按 16:9 自适应高度,
// 下面这块区域接管标题、选集、版本切换和简介,信息密度与
// 主流手机视频应用保持一致。桌面端与横屏手机不渲染它。
//
// 选集刻意放在这里而不是弹层里:从操作栏点「选集」只做「展开 + 滚到这里」,
// 播放画面始终可见,不会像浮层那样盖住正在看的内容。
type PlayerMobileTheaterInfoProps = {
media: Media | null
title: string
subtitle?: string
playbackModeLabel?: string
qualityLabel?: string
episodes: Media[]
currentMediaId: string
currentEpisodeIndex: number
currentVersions?: Media[]
onSelectEpisode: (media: Media) => void
onSelectVersion?: (media: Media) => void
/** 选集列表是否展开(展开后由 playlistPanel 接管,提供季/搜索/版本)。 */
playlistOpen: boolean
onTogglePlaylist: () => void
/** 展开状态下的选集内容(复用播放器的选集面板,内嵌渲染)。 */
playlistPanel?: ReactNode
/**
* 每次从操作栏点「选集」都会 +1:即使列表已经展开,也要把这块滚回视野内。
*/
revealToken?: number
}
function episodeShortLabel(ep: Media): string {
if (ep.episode_num > 0) return `${ep.episode_num}`
const title = ep.episode_title?.trim() || ep.title?.trim() || ''
return title ? title.slice(0, 4) : '·'
}
function episodeFullLabel(ep: Media): string {
if (ep.episode_num > 0) {
const title = ep.episode_title?.trim()
return title && title !== ep.title?.trim() ? `第 ${ep.episode_num} 集 · ${title}` : `第 ${ep.episode_num} 集`
}
return ep.episode_title?.trim() || ep.title?.trim() || '未命名'
}
export function PlayerMobileTheaterInfo({
media,
title,
subtitle,
playbackModeLabel,
qualityLabel,
episodes,
currentMediaId,
currentEpisodeIndex,
currentVersions = [],
onSelectEpisode,
onSelectVersion,
playlistOpen,
onTogglePlaylist,
playlistPanel,
revealToken = 0,
}: PlayerMobileTheaterInfoProps) {
const episodeSectionRef = useRef<HTMLElement | null>(null)
const hasEpisodeList = episodes.length > 1
const versionsToSwitch = currentVersions.length > 1 ? currentVersions : []
const overview = media?.overview?.trim() || ''
// 从操作栏点「选集」:把选集区滚回视野内,用户不用自己往上翻。
useEffect(() => {
if (!revealToken) return
episodeSectionRef.current?.scrollIntoView({ block: 'start', behavior: 'smooth' })
}, [revealToken])
return (
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain bg-black px-4 pb-8 pt-3 text-white">
<h1 className="text-[15px] font-semibold leading-snug text-white/95">{title || '加载中…'}</h1>
{subtitle ? <p className="mt-1 text-xs text-white/55">{subtitle}</p> : null}
{(playbackModeLabel || qualityLabel) && (
<div className="mt-2 flex flex-wrap items-center gap-1.5">
{qualityLabel ? (
<span className="rounded-md bg-white/10 px-2 py-0.5 text-[11px] font-medium text-white/80">
{qualityLabel}
</span>
) : null}
{playbackModeLabel ? (
<span className="rounded-md bg-white/10 px-2 py-0.5 text-[11px] text-white/60">
{playbackModeLabel}
</span>
) : null}
</div>
)}
{hasEpisodeList ? (
<section ref={episodeSectionRef} className="mt-4 scroll-mt-2">
<div className="mb-2 flex items-center justify-between">
<h2 className="flex items-center gap-1.5 text-[13px] font-semibold text-white/90">
<ListVideo size={14} className="text-rose-400" />
选集
<span className="font-mono text-[11px] font-normal text-white/45">
{currentEpisodeIndex >= 0
? `${currentEpisodeIndex + 1}/${episodes.length}`
: `${episodes.length} 集`}
</span>
</h2>
<button
type="button"
onClick={onTogglePlaylist}
className="flex min-h-8 items-center gap-1 rounded-md px-2 text-[11px] text-white/60 transition hover:bg-white/10 hover:text-white"
>
{playlistOpen ? '收起' : '全部'}
<ChevronDown
size={13}
className={`transition-transform ${playlistOpen ? 'rotate-180' : ''}`}
/>
</button>
</div>
{playlistOpen && playlistPanel ? (
playlistPanel
) : (
<div className="scrollbar-hide -mx-4 flex gap-1.5 overflow-x-auto px-4 pb-1">
{episodes.map((ep) => {
const active = mediaVersionMatches(ep, currentMediaId)
return (
<button
key={ep.id}
type="button"
onClick={() => onSelectEpisode(ep)}
title={episodeFullLabel(ep)}
className={`flex h-10 min-w-12 shrink-0 items-center justify-center rounded-lg px-2 text-xs font-medium tabular-nums transition ${
active
? 'bg-rose-500 text-white'
: 'bg-white/[0.07] text-white/80 active:bg-white/15'
}`}
>
{episodeShortLabel(ep)}
</button>
)
})}
</div>
)}
</section>
) : null}
{/* 多版本切换:展开选集后由选集面板顶部的版本区接管,这里不重复一份 */}
{versionsToSwitch.length > 0 && !playlistOpen ? (
<section className="mt-4">
<h2 className="mb-2 flex items-center gap-1.5 text-[13px] font-semibold text-white/90">
<Layers size={13} className="text-rose-400" />
版本
<span className="font-mono text-[11px] font-normal text-white/45">
{versionsToSwitch.length} 个
</span>
</h2>
<div className="space-y-1.5">
{versionsToSwitch.map((version) => {
const active = mediaVersionMatches(version, currentMediaId)
return (
<button
key={version.id}
type="button"
disabled={active}
onClick={() => onSelectVersion?.(version)}
className={`flex w-full items-center gap-2 rounded-lg border px-2.5 py-2 text-left transition disabled:cursor-default ${
active
? 'border-rose-500/50 bg-rose-500/15 text-white'
: 'border-white/5 bg-white/5 text-white/85 active:bg-white/10'
}`}
>
<span className="min-w-0 flex-1 truncate text-xs font-medium">
{mediaVersionLabel(version)}
</span>
{active ? <span className="shrink-0 text-[10px] text-rose-300">播放中</span> : null}
</button>
)
})}
</div>
</section>
) : null}
{overview ? (
<section className="mt-4">
<h2 className="mb-1.5 text-[13px] font-semibold text-white/90">简介</h2>
<p className="text-xs leading-relaxed text-white/60">{overview}</p>
</section>
) : null}
<p className="mt-5 text-center text-[10px] text-white/30">将手机横放可获得全屏沉浸式播放体验</p>
</div>
)
}
+287 -126
View File
@@ -1,9 +1,16 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Check, Film, ListVideo, Play, Search, X } from 'lucide-react'
import { Check, Cloud, Film, Layers, Play, Search, X } from 'lucide-react'
import { imageURL } from '../api/client'
import type { Media } from '../types'
import { seasonLabel, seasonSortOrder, seriesTitleFromPath } from '../utils/groupSeries'
import {
isStrmMedia,
mediaVersionFileName,
mediaVersionLabel,
mediaVersionMatches,
mediaVersionSourceLabel,
} from '../utils/mediaVersion'
import { PLAYER_DRAWER, PLAYER_ICON_BUTTON, PLAYER_PANEL_HEADER } from './playerTheme'
export type SeasonGroup = {
season: number
@@ -14,20 +21,46 @@ type PlayerPlaylistPanelProps = {
open: boolean
onClose: () => void
currentMediaId: string
/** 当前正在播放媒体的全部版本(含自身)。多版本时面板顶部展示版本切换。 */
currentVersions?: Media[]
episodes: Media[]
onSelectEpisode: (media: Media) => void
/** 切换到同一集/同一条目的另一个版本。 */
onSelectVersion?: (media: Media) => void
/**
* 竖屏剧场模式:不再以浮层形式盖住播放画面,而是内嵌在视频下方的
* 内容区里(外层负责标题与「收起」按钮),选集不再遮挡播放器。
*/
inline?: boolean
}
/**
* 选集面板 —— 右侧整条抽屉,内容参考 B 站的番剧选集。
*
* 以前这里是「悬浮卡片 + 每集一行缩略图」,两三百集的库翻起来很累,卡片本身
* 也和画面上别的浮层长得不一样。现在改成:整条贴住播放区右边缘的抽屉,剧集用
* 编号宫格排(一屏能看到几十集),标题/时长放在悬停提示里;当前播放的那一格用
* 强调色填充。
*
* 多版本(同一部片的 4K / 1080p / 云端直链等)依旧不丢:宫格右上角的小角标表示
* 这一集有多个版本,点角标会在宫格上方展开版本列表;当前正在播放的条目的版本
* 则常驻在抽屉顶部,随时可换。单条媒体(电影)没有剧集列表时,抽屉本身就是
* 版本选择器。
*/
export function PlayerPlaylistPanel({
open,
onClose,
currentMediaId,
currentVersions = [],
episodes,
onSelectEpisode,
onSelectVersion,
inline = false,
}: PlayerPlaylistPanelProps) {
const [filterText, setFilterText] = useState('')
const activeItemRef = useRef<HTMLDivElement | null>(null)
const listContainerRef = useRef<HTMLDivElement | null>(null)
/** 正在挑选版本的剧集 id;null = 宫格直接播放。 */
const [versionsForEpisodeId, setVersionsForEpisodeId] = useState<string | null>(null)
const activeItemRef = useRef<HTMLButtonElement | null>(null)
// 按季分组
const seasonGroups = useMemo<SeasonGroup[]>(() => {
@@ -46,9 +79,9 @@ export function PlayerPlaylistPanel({
.map(([season, list]) => ({ season, episodes: list }))
}, [episodes])
// 当前播放所在季
// 当前播放所在季(播放中的可能是某个剧集行的非首个版本,需按版本组比对)
const currentSeason = useMemo(() => {
const found = episodes.find((e) => e.id === currentMediaId)
const found = episodes.find((e) => mediaVersionMatches(e, currentMediaId))
if (!found) return seasonGroups[0]?.season ?? 1
return found.episode_num > 0 ? (found.season_num ?? 0) : (found.season_num || 1)
}, [episodes, currentMediaId, seasonGroups])
@@ -62,15 +95,19 @@ export function PlayerPlaylistPanel({
}
}, [open, currentSeason])
// 换集/换季时收起版本列表:那张列表是针对上一集的,留着会让人误点。
useEffect(() => {
setVersionsForEpisodeId(null)
}, [open, selectedSeason, currentMediaId])
// 当面板打开时,自动平滑滚动到当前播放集的位置
useEffect(() => {
if (open && activeItemRef.current) {
const timer = setTimeout(() => {
activeItemRef.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
}, 100)
return () => clearTimeout(timer)
}
}, [open, selectedSeason, currentMediaId])
if (!open || versionsForEpisodeId) return
const timer = setTimeout(() => {
activeItemRef.current?.scrollIntoView({ block: 'center', behavior: 'smooth' })
}, 100)
return () => clearTimeout(timer)
}, [open, selectedSeason, currentMediaId, versionsForEpisodeId])
if (!open) return null
@@ -86,35 +123,109 @@ export function PlayerPlaylistPanel({
})
: listToDisplay
const versionsToSwitch = currentVersions.length > 1 ? currentVersions : []
// 只有一集(电影/单条媒体)时,面板就是版本选择器,不再重复列出一行「剧集」。
const hasEpisodeList = episodes.length > 1
const versionsForEpisode = versionsForEpisodeId
? (listToDisplay.find((ep) => ep.id === versionsForEpisodeId)?.versions ?? [])
: []
const expandedEpisode = versionsForEpisodeId
? listToDisplay.find((ep) => ep.id === versionsForEpisodeId)
: undefined
return (
<div
onClick={(e) => e.stopPropagation()}
className="absolute inset-x-3 top-12 bottom-3 z-30 flex flex-col overflow-hidden rounded-2xl border border-white/15 bg-black/85 text-white shadow-2xl backdrop-blur-md sm:inset-x-auto sm:right-4 sm:top-16 sm:bottom-20 sm:w-96"
// 舞台用 pointerdown/pointerup 判断「移动端轻触画面」,抽屉上的触摸不该参与。
onPointerDown={(e) => e.stopPropagation()}
onPointerUp={(e) => e.stopPropagation()}
className={
inline
? 'flex flex-col'
: `absolute inset-y-0 right-0 z-30 ${PLAYER_DRAWER}`
}
>
{/* 头部 */}
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3 shrink-0">
<div className="flex items-center gap-2 text-sm font-semibold">
<ListVideo size={17} className="text-rose-400" />
<span>选集列表</span>
<span className="font-mono text-xs font-normal text-white/50">
({episodes.length} 集)
</span>
{/* 内嵌在竖屏剧场布局的视频下方时不自带标题栏:外层已经提供了
「选集 / 收起」标题,重复一层只会挤掉内容。 */}
{inline ? null : (
<div className={PLAYER_PANEL_HEADER}>
<div className="flex min-w-0 items-center gap-2 text-sm font-semibold">
<span className="truncate">{hasEpisodeList ? '选集' : '版本'}</span>
<span className="shrink-0 font-mono text-[11px] font-normal text-white/45">
{hasEpisodeList ? `${episodes.length} 集` : `${versionsToSwitch.length} 个版本`}
</span>
</div>
<button onClick={onClose} className={PLAYER_ICON_BUTTON} title="关闭 (Esc)">
<X size={16} />
</button>
</div>
<button
onClick={onClose}
className="rounded-full p-1 text-white/60 transition hover:bg-white/10 hover:text-white"
title="关闭"
>
<X size={16} />
</button>
</div>
)}
<div className={inline ? undefined : 'flex min-h-0 flex-1 flex-col overflow-hidden'}>
{/* 当前条目的版本切换:多版本时置顶,随时可换 */}
{versionsToSwitch.length > 0 && (
<div className="shrink-0 border-b border-white/10 px-3 py-2.5">
<div className="mb-1.5 flex items-center gap-1.5 text-[11px] font-medium text-white/55">
<Layers size={12} className="text-rose-400" />
<span>正在播放的版本</span>
</div>
<div className="max-h-44 space-y-1 overflow-y-auto pr-0.5">
{versionsToSwitch.map((version) => (
<VersionOption
key={version.id}
version={version}
active={mediaVersionMatches(version, currentMediaId)}
onSelect={() => onSelectVersion?.(version)}
/>
))}
</div>
</div>
)}
{/* 某一集的版本列表:从宫格角标展开,选完即收起 */}
{expandedEpisode && versionsForEpisode.length > 0 && (
<div className="shrink-0 border-b border-white/10 bg-white/[0.03] px-3 py-2.5">
<div className="mb-1.5 flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-1.5 text-[11px] font-medium text-amber-200">
<Layers size={12} className="shrink-0" />
<span className="truncate">
{expandedEpisode.episode_num > 0
? `第 ${expandedEpisode.episode_num} 集 · 选择版本`
: '选择版本'}
</span>
</div>
<button
onClick={() => setVersionsForEpisodeId(null)}
className="shrink-0 rounded p-0.5 text-white/45 transition hover:bg-white/10 hover:text-white"
title="收起版本列表"
>
<X size={13} />
</button>
</div>
<div className="max-h-44 space-y-1 overflow-y-auto pr-0.5">
{versionsForEpisode.map((version) => (
<VersionOption
key={version.id}
version={version}
active={mediaVersionMatches(version, currentMediaId)}
compact
onSelect={() => {
onSelectVersion?.(version)
setVersionsForEpisodeId(null)
}}
/>
))}
</div>
</div>
)}
{/* 季选择 Tabs(若有多季) */}
{seasonGroups.length > 1 && (
<div className="flex items-center gap-1.5 border-b border-white/10 px-3 py-2 shrink-0 overflow-x-auto no-scrollbar">
{hasEpisodeList && seasonGroups.length > 1 && (
<div className="scrollbar-hide flex shrink-0 items-center gap-1.5 overflow-x-auto border-b border-white/10 px-3 py-2">
{seasonGroups.map(({ season, episodes: sesEps }) => {
const isSelected = selectedSeason === season
const isPlayingThisSeason = sesEps.some((e) => e.id === currentMediaId)
const isPlayingThisSeason = sesEps.some((e) => mediaVersionMatches(e, currentMediaId))
return (
<button
key={season}
@@ -140,22 +251,19 @@ export function PlayerPlaylistPanel({
)}
{/* 搜索/过滤单集(当单集数量较多时) */}
{listToDisplay.length > 10 && (
<div className="px-3 pt-2.5 pb-1.5 shrink-0">
<div className="flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-2.5 py-1 text-xs">
<Search size={13} className="text-white/40 shrink-0" />
{hasEpisodeList && listToDisplay.length > 10 && (
<div className="shrink-0 px-3 pb-1 pt-2.5">
<div className="flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-2.5 py-1.5 text-xs">
<Search size={13} className="shrink-0 text-white/40" />
<input
type="text"
value={filterText}
onChange={(e) => setFilterText(e.target.value)}
placeholder="搜索集数或标题…"
className="w-full bg-transparent outline-none placeholder:text-white/30 text-white text-xs"
className="w-full bg-transparent text-xs text-white outline-none placeholder:text-white/30"
/>
{filterText && (
<button
onClick={() => setFilterText('')}
className="text-white/40 hover:text-white"
>
<button onClick={() => setFilterText('')} className="text-white/40 hover:text-white">
<X size={12} />
</button>
)}
@@ -163,100 +271,153 @@ export function PlayerPlaylistPanel({
</div>
)}
{/* 集数列表 */}
<div
ref={listContainerRef}
className="flex-1 overflow-y-auto p-2.5 space-y-1.5 pr-2 select-none"
>
{filteredEpisodes.length === 0 ? (
<div className="py-8 text-center text-xs text-white/40">
{filterText ? '未找到匹配的剧集' : '暂无剧集列表'}
</div>
) : (
filteredEpisodes.map((ep) => {
const isPlaying = ep.id === currentMediaId
const displayTitle = getEpisodeTitle(ep, listToDisplay)
const durationText =
ep.duration_sec > 0 ? `${Math.floor(ep.duration_sec / 60)} 分钟` : ''
{/* 集数宫格:内嵌模式整块跟着外层一起滚动,不再自己滚 */}
{hasEpisodeList ? (
<div className={inline ? 'p-3 select-none' : 'min-h-0 flex-1 overflow-y-auto p-3 select-none'}>
{filteredEpisodes.length === 0 ? (
<div className="py-10 text-center text-xs text-white/40">
{filterText ? '未找到匹配的剧集' : '暂无剧集列表'}
</div>
) : (
<div
className={
inline
? 'grid grid-cols-6 gap-1.5'
: 'grid grid-cols-5 gap-1.5 sm:grid-cols-6'
}
>
{filteredEpisodes.map((ep) => {
const isPlaying = mediaVersionMatches(ep, currentMediaId)
const versions = ep.versions && ep.versions.length > 1 ? ep.versions : []
const displayTitle = getEpisodeTitle(ep, listToDisplay)
const durationText =
ep.duration_sec > 0 ? `${Math.round(ep.duration_sec / 60)} 分钟` : ''
const cellTitle = [
ep.episode_num > 0 ? `第 ${ep.episode_num} 集` : '',
displayTitle,
durationText,
versions.length > 0 ? `${versions.length} 个版本` : '',
]
.filter(Boolean)
.join(' · ')
return (
<div
key={ep.id}
ref={isPlaying ? activeItemRef : null}
onClick={() => onSelectEpisode(ep)}
className={`group flex cursor-pointer items-center gap-2.5 rounded-xl p-2 transition border ${
isPlaying
? 'border-rose-500/60 bg-rose-500/20 text-white'
: 'border-white/5 bg-white/5 hover:border-white/20 hover:bg-white/10 text-white/85'
}`}
>
{/* 封面/集号 */}
<div className="relative flex h-11 w-16 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-white/10 text-xs font-semibold">
{ep.backdrop_url || ep.poster_url ? (
<img
src={imageURL(ep.backdrop_url || ep.poster_url || '', ep.updated_at)}
alt=""
className="h-full w-full object-cover"
referrerPolicy="no-referrer"
/>
) : (
<Film size={16} className="text-white/40" />
)}
{/* 正在播放动效 / 集数徽标 */}
{isPlaying ? (
<div className="absolute inset-0 flex items-center justify-center bg-black/60 backdrop-blur-xs">
<div className="flex items-end gap-0.5 h-3">
<span className="w-0.5 bg-rose-400 animate-pulse h-full" />
<span className="w-0.5 bg-rose-400 animate-pulse h-2" />
<span className="w-0.5 bg-rose-400 animate-pulse h-3" />
</div>
</div>
) : (
<div className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity">
<Play size={14} className="text-white fill-white" />
</div>
)}
{/* 角标显示集数 */}
<span className="absolute bottom-0.5 right-1 rounded bg-black/75 px-1 py-0.2 text-[9px] font-mono text-white/90">
{ep.episode_num > 0 ? `${ep.episode_num}` : '—'}
</span>
</div>
{/* 标题 & 时长 */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<p
className={`truncate text-xs font-medium ${
isPlaying ? 'text-rose-300 font-semibold' : 'group-hover:text-white'
return (
<div key={ep.id} className="relative">
<button
ref={isPlaying ? activeItemRef : null}
onClick={() => onSelectEpisode(ep)}
title={cellTitle}
className={`flex ${inline ? 'h-10' : 'h-9'} w-full items-center justify-center rounded-md text-xs font-medium tabular-nums transition ${
isPlaying
? 'bg-rose-500 text-white shadow-[0_0_0_1px_rgba(255,255,255,0.2)_inset]'
: 'bg-white/[0.06] text-white/80 hover:bg-white/15 hover:text-white'
}`}
>
{displayTitle}
</p>
</div>
<div className="flex items-center gap-2 mt-0.5 text-[10px] text-white/50">
{ep.episode_num > 0 && (
<span className="font-mono">第 {ep.episode_num} 集</span>
{isPlaying ? (
<Play size={12} className="fill-current" />
) : ep.episode_num > 0 ? (
ep.episode_num
) : (
<Film size={13} className="text-white/45" />
)}
</button>
{/* 有多个版本:点角标挑版本,点格子本身直接播放默认版本 */}
{versions.length > 0 && (
<button
onClick={(event) => {
event.stopPropagation()
setVersionsForEpisodeId((current) =>
current === ep.id ? null : ep.id,
)
}}
className={`absolute right-0 top-0 flex ${inline ? 'h-5 w-5 text-[10px]' : 'h-3.5 w-3.5 text-[9px]'} items-center justify-center rounded-bl-md rounded-tr-md font-semibold leading-none transition ${
versionsForEpisodeId === ep.id
? 'bg-amber-300 text-black'
: 'bg-black/45 text-amber-200 hover:bg-black/70'
}`}
title={`${versions.length} 个版本,点这里选择`}
>
{versions.length}
</button>
)}
{durationText && <span>{durationText}</span>}
</div>
</div>
{isPlaying && (
<div className="shrink-0 flex items-center gap-1 text-[11px] font-medium text-rose-400 px-1">
<Check size={13} />
</div>
)}
</div>
)
})
)}
)
})}
</div>
)}
</div>
) : (
versionsToSwitch.length === 0 && (
<div className="flex items-center justify-center px-4 py-8 text-center text-xs text-white/40">
暂无可切换的内容
</div>
)
)}
</div>
</div>
)
}
/** 单个版本选项:主行是画质/容器/体积,次行是来源与文件名。 */
function VersionOption({
version,
active,
compact = false,
onSelect,
}: {
version: Media
active: boolean
compact?: boolean
onSelect: () => void
}) {
const cloud = isStrmMedia(version)
const label = mediaVersionLabel(version)
const fileName = mediaVersionFileName(version)
const sourceLabel = mediaVersionSourceLabel(version)
// 没有探测数据时(网盘 STRM 常见)主行本身就是文件名,次行再重复一遍没有意义。
const showFileName = fileName !== '' && !labelKey(label).includes(labelKey(fileName))
const activeClass = 'border-rose-500/50 bg-rose-500/15 text-white'
const idleClass =
'border-white/5 bg-white/5 text-white/85 hover:border-white/15 hover:bg-white/10'
return (
<button
type="button"
disabled={active}
onClick={onSelect}
title={version.path || version.strm_url || undefined}
className={`flex w-full items-start gap-2 rounded-lg border px-2.5 text-left transition disabled:cursor-default ${
compact ? 'py-1.5' : 'py-2'
} ${active ? activeClass : idleClass}`}
>
<span className="mt-0.5 shrink-0">
{cloud ? (
<Cloud size={13} className={active ? 'text-rose-300' : 'text-white/45'} />
) : (
<Film size={13} className={active ? 'text-rose-300' : 'text-white/45'} />
)}
</span>
<span className="min-w-0 flex-1">
<span
className={`block truncate text-xs font-medium ${active ? 'text-rose-200' : 'text-white/90'}`}
>
{label}
</span>
<span className="mt-0.5 block truncate text-[10px] text-white/45">
{sourceLabel}
{showFileName ? ` · ${fileName}` : ''}
</span>
</span>
{active && <Check size={13} className="mt-0.5 shrink-0 text-rose-400" />}
</button>
)
}
/** 归一化后比较:忽略大小写与分隔符(. _ - 空格),用于判断两段文案是否只是重复。 */
function labelKey(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]+/g, '')
}
function getEpisodeTitle(ep: Media, siblings: Media[]): string {
const title = ep.episode_title?.trim()
if (title && !looksLikeSeriesTitle(ep, title, siblings)) {
+125 -52
View File
@@ -1,7 +1,15 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { PointerEvent as ReactPointerEvent, RefObject } from 'react'
import { Check, Compass, Minus, Plus, RotateCcw, Settings2, Smartphone } from 'lucide-react'
import { Compass, LogOut, Minus, Plus, RotateCcw, Settings2, Smartphone } from 'lucide-react'
import {
PLAYER_ICON_BUTTON,
PLAYER_SEGMENT,
PLAYER_SEGMENT_GROUP,
PLAYER_SEGMENT_OFF,
PLAYER_SEGMENT_ON,
PLAYER_TEXT_BUTTON,
} from './playerTheme'
import {
VR360_DEFAULT_FOV,
VR360_MAX_FOV,
@@ -74,6 +82,13 @@ type Vr360StageProps = {
onError: (message: string) => void
/** 用户调整投影方式/立体布局时回调,由播放页保存为下次进入的默认值。 */
onProfileChange?: (profile: Vr360Profile) => void
/** 退出 VR 全景播放(回到普通播放)。不提供时工具条上不显示退出入口。 */
onExitVr?: () => void
/**
* 鼠标是否停留在 VR 工具条/设置面板上。由播放页转给控制栏:悬停这些浮层时
* 控制栏不应该自动隐藏(否则设置面板会在点击前消失)。
*/
onUiHoldChange?: (hold: boolean) => void
}
/** 每像素拖拽对应的转角(弧度)。视场角越小时画面越「拉近」,拖拽也应更细腻。 */
@@ -96,6 +111,20 @@ function screenOrientationAngle(): number {
return typeof legacy === 'number' ? legacy : 0
}
/**
* 当前设备是否没有真正的鼠标悬停能力(手机/平板)。
*
* 触摸设备上「悬停保持显示」这套机制本身就是错的:手指抬起后浏览器不保证补发
* mouseleave,一旦走进悬停状态标记就再也清不掉,控制栏和 VR 工具条会永久留在
* 画面上。这里按设备能力判断,而不是去猜某个鼠标事件是不是触摸合成的——
* 后者依赖 MouseEvent.sourceCapabilities,实测在不少浏览器里是 null,判断会
* 静默失效、等于没修。
*/
function isHoverlessDevice(): boolean {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return true
return window.matchMedia('(hover: none)').matches
}
type DeviceOrientationEventConstructor = {
requestPermission?: () => Promise<'granted' | 'denied' | 'default'>
}
@@ -117,6 +146,8 @@ export function Vr360Stage({
onReady,
onError,
onProfileChange,
onExitVr,
onUiHoldChange,
}: Vr360StageProps) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const rendererRef = useRef<Vr360Renderer | null>(null)
@@ -372,6 +403,15 @@ export function Vr360Stage({
useEffect(() => () => stopGyro(), [stopGyro])
// 控制栏/浮层隐藏时收起「画面」设置面板:它属于同一套浮层,下次唤出控制栏时
// 不应该自动弹回来挡住画面。
useEffect(() => {
if (uiVisible) return
setSettingsOpen(false)
// 浮层不可见时鼠标离开事件不会再触发,这里主动解除「悬停保持显示」。
onUiHoldChange?.(false)
}, [uiVisible, onUiHoldChange])
const zoomBy = useCallback(
(factor: number) => {
const view = viewRef.current
@@ -400,6 +440,10 @@ export function Vr360Stage({
}, [])
const handlePointerDown = (event: ReactPointerEvent<HTMLCanvasElement>) => {
// 轻触画面把视角拖动之外的浮层悬停状态清掉:触摸设备不会再发出 mouseleave,
// 上一步点击工具条/设置面板留下的「悬停保持显示」会一直挂着,控制栏就永远
// 不隐藏了。用户已经在点画面,说明指针早已离开浮层。
if (event.pointerType === 'touch') onUiHoldChange?.(false)
try {
event.currentTarget.setPointerCapture?.(event.pointerId)
} catch {
@@ -487,7 +531,10 @@ export function Vr360Stage({
<canvas
ref={canvasRef}
data-vr360-surface
className="absolute inset-0 h-full w-full cursor-grab touch-none select-none active:cursor-grabbing"
className={`absolute inset-0 h-full w-full touch-none select-none ${
// 浮层隐藏时连鼠标指针一起藏起来:全景画面里光标一直悬在画面中间很出戏。
uiVisible ? 'cursor-grab active:cursor-grabbing' : 'cursor-none'
}`}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={endPointer}
@@ -497,29 +544,43 @@ export function Vr360Stage({
onClick={(event) => event.stopPropagation()}
/>
<div
// 阻止浮层内按钮/面板的点击冒泡到舞台的 handleSurfaceActivate:否则点击
// 投影方式/画幅布局等设置项会被误判为「单击收起控制栏」,面板在切换后立即消失。
onClick={(event) => event.stopPropagation()}
className={`pointer-events-none absolute inset-x-0 top-2 z-20 flex flex-col items-center gap-1.5 px-2 transition-opacity duration-300 ${
uiVisible ? 'opacity-100' : 'opacity-0'
}`}
>
<div className="pointer-events-auto flex flex-wrap items-center justify-center gap-1.5 rounded-2xl border border-white/15 bg-black/65 px-2 py-1.5 text-white shadow-2xl backdrop-blur">
<div
className={`flex flex-wrap items-center justify-center gap-0.5 rounded-xl border border-white/10 bg-[#0d0e12]/90 p-1 text-white shadow-[0_10px_38px_rgba(0,0,0,0.6)] backdrop-blur-xl ${
// 浮层隐藏时必须彻底不接收指针事件:否则看不见的按钮既会在画面顶部
// 抢走拖动转视角的手势,也可能被误点(例如误开陀螺仪、误开设置面板)。
uiVisible ? 'pointer-events-auto' : 'pointer-events-none'
}`}
// 触摸设备不参与「悬停保持显示」:手指抬起后不会补发 mouseleave,
// 标记会永久挂在打开状态,控制栏和 VR 工具条就再也不自动隐藏。
onMouseEnter={() => {
if (isHoverlessDevice()) return
onUiHoldChange?.(true)
}}
onMouseLeave={() => onUiHoldChange?.(false)}
>
<button
type="button"
onClick={toggleGyro}
className={`flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition ${
gyroState === 'active'
? 'bg-rose-500/90 text-white hover:bg-rose-500'
: 'bg-white/10 text-white/85 hover:bg-white/20'
className={`${PLAYER_TEXT_BUTTON} ${
gyroState === 'active' ? 'bg-rose-500 text-white hover:bg-rose-500' : ''
}`}
title="用手机陀螺仪转动视角"
>
<Smartphone size={14} />
陀螺仪
{gyroState === 'active' && <Compass size={13} className="text-white/85" />}
{gyroState === 'active' && <Compass size={12} className="text-white/85" />}
</button>
<button
type="button"
onClick={resetView}
className="flex items-center gap-1.5 rounded-full bg-white/10 px-2.5 py-1 text-xs font-medium text-white/85 transition hover:bg-white/20"
className={PLAYER_TEXT_BUTTON}
title="恢复默认视角与视场角"
>
<RotateCcw size={14} />
@@ -528,83 +589,95 @@ export function Vr360Stage({
<button
type="button"
onClick={() => setSettingsOpen((open) => !open)}
className={`flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition ${
settingsOpen
? 'bg-rose-500/90 text-white hover:bg-rose-500'
: 'bg-white/10 text-white/85 hover:bg-white/20'
}`}
className={`${PLAYER_TEXT_BUTTON} ${settingsOpen ? 'bg-white/15 text-white' : ''}`}
title="投影方式与画幅布局"
>
<Settings2 size={14} />
画面
</button>
<div className="flex items-center gap-0.5">
<button
type="button"
onClick={() => zoomBy(1.15)}
className="rounded-full p-1.5 transition hover:bg-white/15"
title="视野拉远"
>
<Minus size={15} />
</button>
<span className="w-9 text-center text-[10px] tabular-nums text-white/75">
{(VR360_DEFAULT_FOV / fovHint).toFixed(1)}×
</span>
<button
type="button"
onClick={() => zoomBy(1 / 1.15)}
className="rounded-full p-1.5 transition hover:bg-white/15"
title="视野拉近"
>
<Plus size={15} />
</button>
</div>
<div className="mx-0.5 h-5 w-px bg-white/10" />
<button
type="button"
onClick={() => zoomBy(1.15)}
className={PLAYER_ICON_BUTTON}
title="视野拉远"
>
<Minus size={15} />
</button>
<span className="w-9 text-center text-[10px] tabular-nums text-white/70">
{(VR360_DEFAULT_FOV / fovHint).toFixed(1)}×
</span>
<button
type="button"
onClick={() => zoomBy(1 / 1.15)}
className={PLAYER_ICON_BUTTON}
title="视野拉近"
>
<Plus size={15} />
</button>
{onExitVr ? (
<>
<div className="mx-0.5 h-5 w-px bg-white/10" />
<button
type="button"
onClick={onExitVr}
className={`${PLAYER_TEXT_BUTTON} hover:bg-rose-500/20 hover:text-rose-200`}
title="退出 VR 全景,回到普通播放"
>
<LogOut size={14} />
退出 VR
</button>
</>
) : null}
</div>
<p className="pointer-events-none rounded-full bg-black/45 px-3 py-1 text-[10px] text-white/70 backdrop-blur">
<p className="pointer-events-none rounded-md bg-black/45 px-3 py-1 text-[10px] text-white/65 backdrop-blur">
{hint || '拖动旋转视角 · 滚轮或双指缩放'}
</p>
{settingsOpen ? (
<div className="pointer-events-auto w-[min(92vw,420px)] rounded-2xl border border-white/15 bg-black/75 px-3 py-2.5 text-white shadow-2xl backdrop-blur">
<p className="mb-1.5 text-[10px] uppercase tracking-wide text-white/45">投影方式</p>
<div className="flex flex-wrap gap-1.5">
<div
className={`w-[min(92vw,420px)] rounded-xl border border-white/10 bg-[#0d0e12]/95 px-3 py-3 text-white shadow-[0_10px_38px_rgba(0,0,0,0.6)] backdrop-blur-xl ${
uiVisible ? 'pointer-events-auto' : 'pointer-events-none'
}`}
onMouseEnter={() => {
if (isHoverlessDevice()) return
onUiHoldChange?.(true)
}}
onMouseLeave={() => onUiHoldChange?.(false)}
>
<p className="mb-1.5 text-[10px] uppercase tracking-wide text-white/40">投影方式</p>
<div className={PLAYER_SEGMENT_GROUP}>
{VR360_PROJECTION_OPTIONS.map(([value, label]) => (
<button
key={value}
type="button"
onClick={() => onProfileChange?.({ ...profile, projection: value })}
className={`flex items-center gap-1 rounded-lg px-2.5 py-1 text-[11px] transition ${
profile.projection === value
? 'bg-rose-500/25 text-rose-200'
: 'bg-white/5 text-white/75 hover:bg-white/15'
className={`${PLAYER_SEGMENT} ${
profile.projection === value ? PLAYER_SEGMENT_ON : PLAYER_SEGMENT_OFF
}`}
>
{profile.projection === value && <Check size={11} />}
{label}
</button>
))}
</div>
<p className="mb-1.5 mt-2.5 text-[10px] uppercase tracking-wide text-white/45">
<p className="mb-1.5 mt-3 text-[10px] uppercase tracking-wide text-white/40">
画幅布局
</p>
<div className="flex flex-wrap gap-1.5">
<div className={PLAYER_SEGMENT_GROUP}>
{VR360_STEREO_OPTIONS.map(([value, label]) => (
<button
key={value}
type="button"
onClick={() => onProfileChange?.({ ...profile, stereo: value })}
className={`flex items-center gap-1 rounded-lg px-2.5 py-1 text-[11px] transition ${
profile.stereo === value
? 'bg-rose-500/25 text-rose-200'
: 'bg-white/5 text-white/75 hover:bg-white/15'
className={`${PLAYER_SEGMENT} ${
profile.stereo === value ? PLAYER_SEGMENT_ON : PLAYER_SEGMENT_OFF
}`}
>
{profile.stereo === value && <Check size={11} />}
{label}
</button>
))}
</div>
{profile.stereo !== 'mono' && (
<p className="mt-2 text-[10px] leading-relaxed text-white/45">
<p className="mt-2.5 text-[10px] leading-relaxed text-white/45">
左右/上下并排素材在当前屏幕上只显示左眼画面;真正的双眼立体画面需要 VR
头显(WebXR)。
</p>
+102
View File
@@ -0,0 +1,102 @@
/**
* 播放器浮层的统一视觉语言。
*
* 播放器里现在有十几种浮层:底部操作栏、清晰度/倍速/字幕小弹层、设置面板、
* 选集抽屉、弹幕面板、VR 工具条、错误提示……以前每一处都自己写一遍圆角、
* 边框、透明度,于是同一屏里出现了好几种不同的浮层样式,看起来就「乱」。
*
* 这里把冒泡、排版、图标按钮等重复的类名收敛成常量:改一次全部同步,
* 新加控件也不会再各写一套。
*/
/** 面板底色。比纯黑更「实」,配合 backdrop-blur 后压在画面上仍然清晰可读。 */
const PANEL_BG = 'bg-[#0d0e12]/95'
/** 浮层外圈阴影:暗背景下用大范围柔光,避免出现一圈生硬的黑边。 */
const PANEL_SHADOW = 'shadow-[0_10px_38px_rgba(0,0,0,0.6)]'
/** 小弹层(清晰度/倍速/字幕/设置):贴着操作栏向上弹出。 */
export const PLAYER_POPOVER =
`z-40 overflow-hidden rounded-xl border border-white/10 ${PANEL_BG} py-1 text-white ${PANEL_SHADOW} backdrop-blur-xl`
/**
* 右侧抽屉(选集/弹幕设置):整条贴住播放区域右边缘,通到顶底。
*
* 小屏上留出 12% 的宽度而不是铺满:这样顶栏的返回按钮和标题仍然露在外面,
* 用户不用先关抽屉才能返回(铺满时唯一的出口只剩标题栏上的关闭按钮)。
*/
export const PLAYER_DRAWER =
`flex h-full w-[88%] flex-col border-white/10 ${PANEL_BG} text-white ${PANEL_SHADOW} backdrop-blur-xl sm:w-[380px] sm:border-l`
/**
* 底部动作面板(竖屏剧场模式):贴着整个屏幕底部向上弹出,高度按视口
* 计算(最高 86dvh),因此手机上能容纳的选项远多于贴视频区弹出的版本。
*
* 用 fixed 而不是 absolute:播放区在竖屏剧场模式里只有 16:9 一小块,
* 若以它为定位基准,面板最高只能到视频高度的 72%,内容会被压得很窄。
*/
export const PLAYER_SHEET =
`player-sheet-height fixed inset-x-0 bottom-0 z-40 flex min-h-[10rem] flex-col overflow-hidden rounded-t-2xl border border-b-0 border-white/10 ${PANEL_BG} text-white ${PANEL_SHADOW} backdrop-blur-xl`
/** 底部动作面板的标题栏(带关闭按钮)。 */
export const PLAYER_SHEET_HEADER =
'flex shrink-0 items-center justify-between gap-2 border-b border-white/10 px-4 py-2.5'
/**
* 底部动作面板的可滚动内容区:底部垫上安全区高度避免 iPhone 小横条遮挡,
* 并把面板内所有按钮抬到 40px,手机上手指点得准。
*/
export const PLAYER_SHEET_BODY =
'min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 pb-[calc(0.75rem+env(safe-area-inset-bottom,0px))] pt-2 [&_button]:min-h-10'
/** 操作栏里的图标按钮。所有图标按钮共用同一尺寸与悬停反馈,排在一起才整齐。 */
export const PLAYER_ICON_BUTTON =
'flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-white/85 transition hover:bg-white/15 hover:text-white disabled:cursor-not-allowed disabled:text-white/30 disabled:hover:bg-transparent sm:h-8 sm:w-8'
/** 带文字的按钮(清晰度/倍速/选集)。文字按钮和图标按钮同高,基线才对得齐。 */
export const PLAYER_TEXT_BUTTON =
'flex h-7 shrink-0 items-center gap-1 rounded-md px-1.5 text-[11px] font-medium text-white/85 transition hover:bg-white/15 hover:text-white disabled:cursor-not-allowed disabled:text-white/30 disabled:hover:bg-transparent sm:h-8 sm:px-2 sm:text-xs'
/** 弹层/面板里的一行选项。 */
export const PLAYER_MENU_ITEM =
'flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-xs text-white/85 transition hover:bg-white/10 hover:text-white'
/** 弹层里的小标题(「115 云端」「本地 HLS」这类分组名)。 */
export const PLAYER_MENU_LABEL =
'px-3 pb-1 pt-2 text-[10px] uppercase tracking-wide text-white/35'
/** 分组之间的分隔线。 */
export const PLAYER_MENU_DIVIDER = 'my-1 h-px bg-white/10'
/** 抽屉/面板的标题栏。 */
export const PLAYER_PANEL_HEADER =
'flex shrink-0 items-center justify-between gap-2 border-b border-white/10 px-4 py-3'
/** 分段选择(播放方式、投影方式这类互斥选项)。 */
export const PLAYER_SEGMENT_GROUP = 'flex items-center gap-1 rounded-lg bg-white/5 p-0.5'
export const PLAYER_SEGMENT = 'flex-1 rounded-md px-2 py-1 text-[11px] font-medium transition'
export const PLAYER_SEGMENT_ON = 'bg-rose-500 text-white'
export const PLAYER_SEGMENT_OFF = 'text-white/70 hover:bg-white/10 hover:text-white'
/** 开关状态的绿色小点(例如弹幕已加载、VR 素材已识别)。 */
export const PLAYER_STATUS_DOT = 'h-1.5 w-1.5 shrink-0 rounded-full'
/**
* 进度条/音量条上的隐形 range 输入。
*
* 视觉部分(轨道、已播放段、圆点手柄)全部自己画,原生 range 只负责三件事:
* 点击定位、拖拽、键盘与无障碍语义。所以它必须完全透明——但**不能**
* pointer-events-none,否则拖不动。
*/
export const PLAYER_RANGE_OVERLAY =
'absolute inset-0 h-full w-full cursor-pointer appearance-none bg-transparent outline-none ' +
'[&::-webkit-slider-runnable-track]:bg-transparent ' +
'[&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:appearance-none ' +
'[&::-moz-range-track]:bg-transparent ' +
'[&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:border-0 ' +
'[&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:bg-transparent'
/** 自绘的细轨道。悬停时整条轨道加粗,是 B 站进度条最直观的那点反馈。 */
export const PLAYER_TRACK =
'pointer-events-none absolute inset-x-0 h-[3px] rounded-full bg-white/25 transition-[height] duration-150'
export const PLAYER_TRACK_FILL = 'pointer-events-none absolute inset-y-0 left-0 rounded-full'
+32
View File
@@ -0,0 +1,32 @@
import { useEffect, useState } from 'react'
const THEATER_MAX_WIDTH = 768
function queryTheater(): boolean {
if (typeof window === 'undefined') return false
// 窄屏 + 竖屏才进剧场模式:横屏手机自动回到全屏沉浸式布局。
return window.innerWidth < THEATER_MAX_WIDTH && window.innerHeight >= window.innerWidth
}
/**
* 是否使用移动端竖屏剧场布局。
*
* 桌面端与横屏手机保持原来的全屏居中播放器;只有竖屏手机进
* 「视频贴顶 + 下方内容区」的剧场模式,避免 16:9 视频在竖屏里
* 上下大黑边、控制栏远离画面的问题。
*/
export function useIsMobileTheater(): boolean {
const [theater, setTheater] = useState(queryTheater)
useEffect(() => {
const onChange = () => setTheater(queryTheater())
window.addEventListener('resize', onChange)
window.addEventListener('orientationchange', onChange)
return () => {
window.removeEventListener('resize', onChange)
window.removeEventListener('orientationchange', onChange)
}
}, [])
return theater
}
+52 -1
View File
@@ -1,4 +1,10 @@
import { shouldPersistScrollSample, shouldRememberScroll } from './useScrollMemory.ts'
import {
isJumpIntentKey,
isScrollIntentKey,
shouldPersistClampedSample,
shouldPersistScrollSample,
shouldRememberScroll,
} from './useScrollMemory.ts'
function check(name: string, condition: boolean) {
if (!condition) throw new Error(`useScrollMemory: ${name}`)
@@ -7,6 +13,7 @@ function check(name: string, condition: boolean) {
check('home remembers scroll', shouldRememberScroll('/'))
check('library list remembers scroll', shouldRememberScroll('/libraries'))
check('library detail remembers scroll', shouldRememberScroll('/library/lib-1'))
check('library series panel remembers scroll', shouldRememberScroll('/library/lib-1'))
check('media detail remembers scroll', shouldRememberScroll('/media/media-1'))
check('settings does not remember scroll', !shouldRememberScroll('/settings'))
check('player does not remember scroll', !shouldRememberScroll('/play/media-1'))
@@ -48,4 +55,48 @@ check(
}),
)
// 恢复途中内容还没长出来(滚不动)或被钳在矮内容底部时,scrollTop 是浏览器钳制
// 出来的值,不能写回存储,否则一次误触就把记忆位置清成 0。
check(
'clamped sample while list is still loading is ignored',
!shouldPersistClampedSample({ current: 0, maxScroll: 0, saved: 1800 }),
)
check(
'clamped sample at the bottom of a shrunken list is ignored',
!shouldPersistClampedSample({ current: 1200, maxScroll: 1200, saved: 1800 }),
)
check(
'sample at the bottom of a completely scrolled list stays persistable',
shouldPersistClampedSample({ current: 1800, maxScroll: 1800, saved: 1800 }),
)
check(
'reachable scroll position is persisted',
shouldPersistClampedSample({ current: 900, maxScroll: 1200, saved: 1800 }),
)
check(
'reaching the remembered target is persisted',
shouldPersistClampedSample({ current: 1800, maxScroll: 2600, saved: 1800 }),
)
check(
'deeper than the remembered target is persisted',
shouldPersistClampedSample({ current: 3000, maxScroll: 4000, saved: 1800 }),
)
check(
'intentional scroll to top of a tall list is persisted',
shouldPersistClampedSample({ current: 0, maxScroll: 4000, saved: 1800 }),
)
check(
'unscrollable page without memory cannot persist',
!shouldPersistClampedSample({ current: 0, maxScroll: 0, saved: 0 }),
)
check('page down counts as scroll intent', isScrollIntentKey('PageDown'))
check('space counts as scroll intent', isScrollIntentKey(' '))
check('typing does not count as scroll intent', !isScrollIntentKey('a'))
check('shortcut does not count as scroll intent', !isScrollIntentKey('Meta'))
check('page down allows a long jump', isJumpIntentKey('PageDown'))
check('home allows a long jump', isJumpIntentKey('Home'))
check('arrow key keeps smooth-only intent', !isJumpIntentKey('ArrowDown'))
check('space keeps smooth-only intent', !isJumpIntentKey(' '))
console.log('useScrollMemory.test.ts ok')
+144 -31
View File
@@ -3,6 +3,36 @@ import { useLayoutEffect } from 'react'
const SCROLL_STORAGE_PREFIX = 'mebox.scroll.'
const scrollPositions = new Map<string, number>()
/**
* 用户滚动意图的有效期:滚轮 / 触摸 / 翻页键之后这段时间内的 scroll 事件才算
* “用户自己滚出来的位置”。连续滚动(含惯性)期间每个 scroll 事件都会续期,
* 所以正常滑动不会中途过期,而恢复回填 / 虚拟列表量高这类程序化位移会被排除。
*/
const USER_SCROLL_INTENT_TTL_MS = 800
/** 平滑滚动(滚轮/触摸/方向键)单次采样允许的最大“向上跳”距离。 */
function maxSmoothJumpUp(viewportHeight: number): number {
return Math.max(viewportHeight * 2, 1200)
}
/** 会滚动容器的按键。 */
export function isScrollIntentKey(key: string): boolean {
return (
key === 'PageDown' ||
key === 'PageUp' ||
key === 'Home' ||
key === 'End' ||
key === 'ArrowDown' ||
key === 'ArrowUp' ||
key === ' '
)
}
/** 一次按键可能直接跳很远(首页/末页/翻页),这些不算异常向上跳。 */
export function isJumpIntentKey(key: string): boolean {
return key === 'PageDown' || key === 'PageUp' || key === 'Home' || key === 'End'
}
function readScrollPosition(key: string): number {
const cached = scrollPositions.get(key)
if (cached !== undefined) return cached
@@ -56,21 +86,54 @@ export function shouldPersistScrollSample(input: {
return true
}
/**
* 判断一次滚动采样是不是“用户真正选中的位置”。
*
* 内容还在加载(容器根本滚不动)或被钳在“变矮内容”的底部时,scrollTop 是浏览器
* 钳制出来的值:恢复途中任何一次滚轮 / 触摸 / 按键都会读到这个值,写回存储就会把
* 记忆位置抹成 0(表现为返回媒体库时永远停在顶部,且之后再也不会记录)。
*/
export function shouldPersistClampedSample(input: {
current: number
maxScroll: number
saved: number
}): boolean {
const { current, maxScroll, saved } = input
if (maxScroll <= 0) {
return false
}
if (current >= maxScroll && current < saved) {
return false
}
return true
}
/**
* 记住列表页的滚动位置。页面内容会异步长高,因此恢复期间会监听内容高度,
* 直到目标位置可达;期间用户主动滚动会立即接管,避免和恢复逻辑抢滚动条。
*
* 恢复还没完成时,容器里的 scrollTop 往往是浏览器钳制出来的值(内容还在加载
* 占位,或被钳在变矮内容的底部)。这种采样既不写回存储,也不当成用户“接管”,
* 否则一次误触的滚轮 / 触摸就会把记忆位置清成 0。
*
* 只有带“用户滚动意图”(滚轮 / 触摸 / 翻页键 / 拖滚动条)的 scroll 事件才会写
* 存储:恢复回填、虚拟列表挂载量高、浏览器钳制造成的位移都不算用户选择的位置。
*
* routeKey 允许包含 query:媒体库详情页的剧集面板(`/library/:id?series=...`)
* 与网格视图是两块不同内容,各自记一份位置,互不覆盖。
*/
export function useScrollMemory(pathname: string, userKey = 'anonymous'): void {
export function useScrollMemory(routeKey: string, userKey = 'anonymous'): void {
useLayoutEffect(() => {
const el = document.getElementById('app-main-scroll')
if (!el) return
const pathname = routeKey.split('?')[0]
if (!shouldRememberScroll(pathname)) {
el.scrollTop = 0
return
}
const key = `${userKey}:${pathname}`
const key = `${userKey}:${routeKey}`
const saved = readScrollPosition(key)
let restoring = saved > 0
let observer: ResizeObserver | null = null
@@ -79,6 +142,36 @@ export function useScrollMemory(pathname: string, userKey = 'anonymous'): void {
let restorePumpUntil = 0
let lastSaved = saved
let lastHeight = el.scrollHeight
// 最近一次“用户主动滚动”的时间戳与类型(滚轮 / 触摸 / 翻页键 / 拖滚动条)。
// 只有用户自己滚出来的位置才写回存储:恢复逻辑、虚拟列表挂载量高、浏览器
// 钳制造成的 scrollTop 变化都不代表用户想要的位置,写进去就会把记忆抹掉。
let userScrollUntil = 0
let userScrollAllowsJump = false
const markUserScroll = (allowsJump = false) => {
const now = performance.now()
// 上一段意图已过期:新的一段从“只允许小幅向上跳”开始。
if (userScrollUntil <= now) {
userScrollAllowsJump = false
}
userScrollUntil = now + USER_SCROLL_INTENT_TTL_MS
if (allowsJump) {
userScrollAllowsJump = true
}
}
const userScrolling = () => userScrollUntil > performance.now()
const currentMaxScroll = () => Math.max(0, el.scrollHeight - el.clientHeight)
// 只有“用户真正能滚到”的位置才算有效采样:内容还在加载占位(滚不动)或
// 被钳在变矮内容的底部时,scrollTop 都是浏览器钳制出来的值。
const sampleIsTrustworthy = (current: number) =>
shouldPersistClampedSample({ current, maxScroll: currentMaxScroll(), saved })
const adoptScrollPosition = (current: number) => {
lastSaved = current
lastHeight = el.scrollHeight
writeScrollPosition(key, current)
}
const stopRestore = () => {
if (restoreFrame) {
@@ -97,14 +190,18 @@ export function useScrollMemory(pathname: string, userKey = 'anonymous'): void {
if (!restoring) return
restoring = false
stopRestore()
lastSaved = Math.round(el.scrollTop)
lastHeight = el.scrollHeight
writeScrollPosition(key, lastSaved)
const current = Math.round(el.scrollTop)
if (!sampleIsTrustworthy(current)) {
// 目标一直不可达(内容仍比记忆位置矮 / 还在加载):保留原记忆值,
// 不要把钳制出来的位置写回去。
return
}
adoptScrollPosition(current)
}
const tryRestore = () => {
if (!restoring) return
const max = Math.max(0, el.scrollHeight - el.clientHeight)
const max = currentMaxScroll()
const target = Math.min(saved, max)
if (target > 0 && Math.abs(el.scrollTop - target) > 1) {
el.scrollTop = target
@@ -125,8 +222,30 @@ export function useScrollMemory(pathname: string, userKey = 'anonymous'): void {
}
const saveNow = () => {
if (restoring) return
const current = Math.round(el.scrollTop)
const userDriven = userScrolling()
// 惯性/连续滚动期间持续续期,避免长距离滑动中途被判定为“非用户滚动”。
if (userDriven) markUserScroll(userScrollAllowsJump)
// 内容还没长回来时读到的 scrollTop 不是用户选的位置,既不能写存储,
// 也不能当成“用户接管”。
if (!sampleIsTrustworthy(current)) return
// 一瞬间大幅向上跳(恢复回填、虚拟列表挂载量高、浏览器钳制)只有按键翻页 /
// 拖滚动条这类意图才可能是用户行为,否则直接丢弃。
const upJump = lastSaved > current ? lastSaved - current : 0
if (upJump > maxSmoothJumpUp(el.clientHeight) && !(userDriven && userScrollAllowsJump)) {
return
}
if (restoring) {
if (!userDriven || Math.abs(current - saved) <= 1) return
// 恢复途中用户真的滚到了别处:交还控制权并记录这个位置,避免恢复
// 逻辑继续和用户抢滚动条。
restoring = false
stopRestore()
adoptScrollPosition(current)
return
}
// 非用户滚动(恢复逻辑回填、虚拟列表挂载/量高、浏览器钳制)不写存储。
if (!userDriven) return
const height = el.scrollHeight
if (
!shouldPersistScrollSample({
@@ -136,7 +255,6 @@ export function useScrollMemory(pathname: string, userKey = 'anonymous'): void {
lastHeight,
})
) {
lastHeight = height
return
}
lastHeight = height
@@ -145,13 +263,13 @@ export function useScrollMemory(pathname: string, userKey = 'anonymous'): void {
writeScrollPosition(key, current)
}
const cancelRestore = () => {
if (!restoring) return
restoring = false
stopRestore()
lastSaved = Math.round(el.scrollTop)
lastHeight = el.scrollHeight
writeScrollPosition(key, lastSaved)
// 用户滚动意图:滚轮 / 触摸 = 平滑滚动;翻页键 / 拖滚动条 = 允许直接跳很远。
const onScrollIntent = () => {
markUserScroll(false)
}
const onJumpIntent = () => {
markUserScroll(true)
}
if (saved <= 0) {
@@ -171,42 +289,37 @@ export function useScrollMemory(pathname: string, userKey = 'anonymous'): void {
}
const onPointerDown = (event: PointerEvent) => {
// 点在容器本身 = 拖滚动条或在空白处按下:允许大幅跳转。
if (event.target === el) {
cancelRestore()
onJumpIntent()
}
saveNow()
}
const onKeyDown = (event: KeyboardEvent) => {
if (
event.key === 'PageDown' ||
event.key === 'PageUp' ||
event.key === 'Home' ||
event.key === 'End' ||
event.key === 'ArrowDown' ||
event.key === 'ArrowUp' ||
event.key === ' '
) {
cancelRestore()
if (isJumpIntentKey(event.key)) {
onJumpIntent()
} else if (isScrollIntentKey(event.key)) {
onScrollIntent()
}
saveNow()
}
el.addEventListener('scroll', saveNow, { passive: true })
el.addEventListener('pointerdown', onPointerDown, true)
window.addEventListener('wheel', cancelRestore, { passive: true, capture: true })
window.addEventListener('touchstart', cancelRestore, { passive: true, capture: true })
window.addEventListener('wheel', onScrollIntent, { passive: true, capture: true })
window.addEventListener('touchstart', onScrollIntent, { passive: true, capture: true })
window.addEventListener('keydown', onKeyDown, true)
return () => {
el.removeEventListener('scroll', saveNow)
el.removeEventListener('pointerdown', onPointerDown, true)
window.removeEventListener('wheel', cancelRestore, true)
window.removeEventListener('touchstart', cancelRestore, true)
window.removeEventListener('wheel', onScrollIntent, true)
window.removeEventListener('touchstart', onScrollIntent, true)
window.removeEventListener('keydown', onKeyDown, true)
stopRestore()
// 清理时写入最后一次有效位置,避免依赖已被钳制的 el.scrollTop。
writeScrollPosition(key, lastSaved)
}
}, [pathname, userKey])
}, [routeKey, userKey])
}
+8
View File
@@ -272,6 +272,14 @@ body {
}
.scrollbar-hide::-webkit-scrollbar { display: none; }
/* ── 播放器底部动作面板的高度上限 ──
手机上按视口高度算(86%),老浏览器不认识 dvh 时退回 vh,避免面板
在 flex 容器里被内容撑出屏幕。 */
.player-sheet-height {
max-height: 86vh;
max-height: 86dvh;
}
/* ─── Reduced Motion ─── */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {

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