mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 47899284a5 | |||
| c24ebe181d | |||
| 9b2b592003 | |||
| 0c4668c156 | |||
| 474513f18a | |||
| 0810bfb234 | |||
| 61a7260eaf | |||
| 45f4763b0c | |||
| f4f57759ce | |||
| 6ec9adc976 | |||
| f4abdf78e2 |
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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 数组
|
||||
```
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,在低配主机
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)}, "|")))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)。
|
||||
|
||||
@@ -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 }
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+41
-1
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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))
|
||||
}
|
||||
}
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Vendored
+74
-1
@@ -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') || ''
|
||||
|
||||
@@ -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)}` : ''
|
||||
|
||||
@@ -89,6 +89,7 @@ export function Layout() {
|
||||
)}
|
||||
<LayoutWorkspace
|
||||
routeKey={location.pathname}
|
||||
scrollKey={`${location.pathname}${location.search}`}
|
||||
userKey={user?.id}
|
||||
showMobileBottomNav={showMobileBottomNav}
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Captions,
|
||||
CaptionsOff,
|
||||
Check,
|
||||
ChevronDown,
|
||||
FastForward,
|
||||
Layers,
|
||||
ListVideo,
|
||||
@@ -46,6 +47,9 @@ import {
|
||||
PLAYER_MENU_LABEL,
|
||||
PLAYER_POPOVER,
|
||||
PLAYER_RANGE_OVERLAY,
|
||||
PLAYER_SHEET,
|
||||
PLAYER_SHEET_BODY,
|
||||
PLAYER_SHEET_HEADER,
|
||||
PLAYER_TEXT_BUTTON,
|
||||
PLAYER_TRACK,
|
||||
PLAYER_TRACK_FILL,
|
||||
@@ -193,10 +197,17 @@ type PlayerControlsProps = {
|
||||
streamOffset?: number
|
||||
/** Absolute seek on the full timeline; return true when handled (e.g. HLS restart). */
|
||||
onSeekAbsolute?: (seconds: number) => boolean
|
||||
/**
|
||||
* 竖屏剧场模式:控制栏按钮放大到触控尺寸(44px 起),清晰度/倍速/
|
||||
* 选集/字幕弹层改为视频区底部的动作面板,避免右侧抽屉与悬浮小弹层
|
||||
* 在窄屏下被裁剪。桌面端保持原样。
|
||||
*/
|
||||
theater?: boolean
|
||||
}
|
||||
|
||||
export function PlayerControls({
|
||||
videoRef,
|
||||
theater = false,
|
||||
volume: volumeProp = 1,
|
||||
onVolumeChange,
|
||||
onVolumeCommit,
|
||||
@@ -271,6 +282,8 @@ export function PlayerControls({
|
||||
const speedMenuRef = useRef<HTMLDivElement | null>(null)
|
||||
const [settingsMenuOpen, setSettingsMenuOpen] = useState(false)
|
||||
const settingsMenuRef = useRef<HTMLDivElement | null>(null)
|
||||
/** 竖屏剧场模式的设置面板(相对整个屏幕固定)——点击面板内部不应把它关掉。 */
|
||||
const settingsSheetRef = useRef<HTMLDivElement | null>(null)
|
||||
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const isScrubbingRef = useRef(false)
|
||||
/** 控制栏当前是否处于「必须保持显示」的状态(悬停/拖进度/菜单或面板打开)。 */
|
||||
@@ -382,6 +395,10 @@ export function PlayerControls({
|
||||
useEffect(() => {
|
||||
if (!subtitleMenuOpen && !qualityMenuOpen && !speedMenuOpen && !settingsMenuOpen) return
|
||||
const onDocClick = (e: MouseEvent) => {
|
||||
// 剧场模式的设置面板挂在操作栏之外,先判断:点面板内部不关任何弹层。
|
||||
if (settingsSheetRef.current && settingsSheetRef.current.contains(e.target as Node)) {
|
||||
return
|
||||
}
|
||||
if (subtitleMenuRef.current && !subtitleMenuRef.current.contains(e.target as Node)) {
|
||||
setSubtitleMenuOpen(false)
|
||||
}
|
||||
@@ -910,7 +927,7 @@ export function PlayerControls({
|
||||
const compactOnlySections = (
|
||||
<>
|
||||
{showQuality && hasQualityGroups && (
|
||||
<div className="sm:hidden">
|
||||
<div className={theater ? undefined : 'sm:hidden'}>
|
||||
<p className={PLAYER_MENU_LABEL}>清晰度</p>
|
||||
<QualityItems
|
||||
groups={qualityGroups}
|
||||
@@ -919,7 +936,7 @@ export function PlayerControls({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="sm:hidden">
|
||||
<div className={theater ? undefined : 'sm:hidden'}>
|
||||
<p className={PLAYER_MENU_LABEL}>播放速度</p>
|
||||
<SpeedItems
|
||||
playbackRate={playbackRateProp}
|
||||
@@ -927,7 +944,7 @@ export function PlayerControls({
|
||||
/>
|
||||
</div>
|
||||
{onTogglePlaylist && (
|
||||
<div className="sm:hidden">
|
||||
<div className={theater ? undefined : 'sm:hidden'}>
|
||||
<p className={PLAYER_MENU_LABEL}>选集</p>
|
||||
<button
|
||||
type="button"
|
||||
@@ -947,7 +964,7 @@ export function PlayerControls({
|
||||
</div>
|
||||
)}
|
||||
{subs.length > 0 && (
|
||||
<div className="sm:hidden">
|
||||
<div className={theater ? undefined : 'sm:hidden'}>
|
||||
<p className={PLAYER_MENU_LABEL}>字幕</p>
|
||||
<SubtitleItems
|
||||
subs={subs}
|
||||
@@ -965,7 +982,7 @@ export function PlayerControls({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={`sm:hidden ${PLAYER_MENU_DIVIDER}`} />
|
||||
<div className={theater ? PLAYER_MENU_DIVIDER : `sm:hidden ${PLAYER_MENU_DIVIDER}`} />
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -1028,6 +1045,28 @@ export function PlayerControls({
|
||||
<>
|
||||
{seekOverlay}
|
||||
{playbackRateOverlay}
|
||||
{theater && settingsMenuOpen ? (
|
||||
<div
|
||||
ref={settingsSheetRef}
|
||||
className={PLAYER_SHEET}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onPointerUp={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className={PLAYER_SHEET_HEADER}>
|
||||
<span className="text-[13px] font-semibold text-white/90">播放器设置</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSettingsMenuOpen(false)}
|
||||
className={`${PLAYER_ICON_BUTTON} h-10 w-10`}
|
||||
title="关闭设置"
|
||||
>
|
||||
<ChevronDown size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className={PLAYER_SHEET_BODY}>{settingsPanel}</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className={`pointer-events-auto absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/85 via-black/45 to-transparent px-2.5 pb-1.5 pt-12 transition-opacity duration-300 sm:px-3.5 sm:pb-2.5 ${
|
||||
uiVisible ? 'opacity-100' : 'pointer-events-none opacity-0'
|
||||
@@ -1042,11 +1081,11 @@ export function PlayerControls({
|
||||
>
|
||||
{/* 进度条:单独占一行贴着画面底部,轨道只有 3px,悬停才变粗并露出圆点手柄 */}
|
||||
<div
|
||||
className="group/seek relative flex h-5 w-full items-center"
|
||||
className={`group/seek relative flex w-full items-center ${theater ? 'h-9' : 'h-5'}`}
|
||||
onMouseMove={handleSeekHover}
|
||||
onMouseLeave={() => setSeekHover(null)}
|
||||
>
|
||||
<div className={`${PLAYER_TRACK} group-hover/seek:h-[5px]`}>
|
||||
<div className={`${PLAYER_TRACK} ${theater ? 'h-[5px]' : ''} group-hover/seek:h-[5px]`}>
|
||||
<div
|
||||
className={`${PLAYER_TRACK_FILL} bg-white/30`}
|
||||
style={{ width: `${bufferedRatio * 100}%` }}
|
||||
@@ -1057,7 +1096,7 @@ export function PlayerControls({
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="pointer-events-none absolute h-3 w-3 -translate-x-1/2 rounded-full bg-white opacity-0 shadow-md transition-opacity duration-150 group-hover/seek:opacity-100"
|
||||
className={`pointer-events-none absolute h-3 w-3 -translate-x-1/2 rounded-full bg-white shadow-md transition-opacity duration-150 group-hover/seek:opacity-100 ${theater ? 'opacity-100' : 'opacity-0'}`}
|
||||
style={{ left: `${playedRatio * 100}%` }}
|
||||
/>
|
||||
{tooltipVisible && durationSafe > 0 ? (
|
||||
@@ -1085,14 +1124,14 @@ export function PlayerControls({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-0.5 flex items-center gap-0.5 sm:mt-1 sm:gap-1">
|
||||
<div className={`flex items-center ${theater ? 'mt-1 gap-1' : 'mt-0.5 gap-0.5 sm:mt-1 sm:gap-1'}`}>
|
||||
{/* 播放 / 暂停 */}
|
||||
<button
|
||||
onClick={togglePlay}
|
||||
className={`${PLAYER_ICON_BUTTON} h-8 w-8 sm:h-9 sm:w-9`}
|
||||
className={`${PLAYER_ICON_BUTTON} ${theater ? 'h-11 w-11' : 'h-8 w-8 sm:h-9 sm:w-9'}`}
|
||||
title={playing ? '暂停 (Space)' : '播放 (Space)'}
|
||||
>
|
||||
{playing ? <Pause size={19} /> : <Play size={19} />}
|
||||
{playing ? <Pause size={theater ? 24 : 19} /> : <Play size={theater ? 24 : 19} />}
|
||||
</button>
|
||||
|
||||
{/* 上一集 / 下一集:单条媒体(电影)没有上下集时整组隐藏,少两个死按钮 */}
|
||||
@@ -1101,7 +1140,7 @@ export function PlayerControls({
|
||||
<button
|
||||
onClick={onPrevEpisode}
|
||||
disabled={!hasPrevEpisode}
|
||||
className={PLAYER_ICON_BUTTON}
|
||||
className={`${PLAYER_ICON_BUTTON} ${theater ? 'h-11 w-11' : ''}`}
|
||||
title={
|
||||
hasPrevEpisode
|
||||
? prevEpisodeTitle
|
||||
@@ -1115,7 +1154,7 @@ export function PlayerControls({
|
||||
<button
|
||||
onClick={onNextEpisode}
|
||||
disabled={!hasNextEpisode}
|
||||
className={PLAYER_ICON_BUTTON}
|
||||
className={`${PLAYER_ICON_BUTTON} ${theater ? 'h-11 w-11' : ''}`}
|
||||
title={
|
||||
hasNextEpisode
|
||||
? nextEpisodeTitle
|
||||
@@ -1223,7 +1262,7 @@ export function PlayerControls({
|
||||
type="button"
|
||||
onClick={onOpenDanmaku}
|
||||
disabled={!onOpenDanmaku}
|
||||
className={`${PLAYER_ICON_BUTTON} ${danmakuOpen ? 'bg-white/15' : ''}`}
|
||||
className={`${PLAYER_ICON_BUTTON} ${theater ? 'h-11 w-11' : ''} ${danmakuOpen ? 'bg-white/15' : ''}`}
|
||||
title={danmakuOpen ? '弹幕设置(已打开)' : '弹幕设置'}
|
||||
>
|
||||
<span
|
||||
@@ -1330,16 +1369,16 @@ export function PlayerControls({
|
||||
setSubtitleMenuOpen(false)
|
||||
setSettingsMenuOpen((v) => !v)
|
||||
}}
|
||||
className={`${PLAYER_ICON_BUTTON} ${settingsMenuOpen ? 'bg-white/15 text-white' : ''}`}
|
||||
className={`${PLAYER_ICON_BUTTON} ${theater ? 'h-11 w-11' : ''} ${settingsMenuOpen ? 'bg-white/15 text-white' : ''}`}
|
||||
title="播放器设置"
|
||||
>
|
||||
<Settings2 size={17} />
|
||||
<Settings2 size={theater ? 21 : 17} />
|
||||
</button>
|
||||
{/* 识别到 VR 素材但还没进 VR 时,用一个小圆点提示设置里有东西可开 */}
|
||||
{vr360Detected && !vr360 && (
|
||||
<span className="pointer-events-none absolute right-0.5 top-0.5 h-1.5 w-1.5 rounded-full bg-amber-300" />
|
||||
)}
|
||||
{settingsMenuOpen && (
|
||||
{settingsMenuOpen && !theater && (
|
||||
<div className={`${PLAYER_POPOVER} absolute bottom-11 right-0 max-h-[70vh] w-[15rem] overflow-y-auto`}>
|
||||
{settingsPanel}
|
||||
</div>
|
||||
@@ -1358,10 +1397,10 @@ export function PlayerControls({
|
||||
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
className={PLAYER_ICON_BUTTON}
|
||||
className={`${PLAYER_ICON_BUTTON} ${theater ? 'h-11 w-11' : ''}`}
|
||||
title={fullscreen ? '退出全屏 (F)' : '全屏 (F)'}
|
||||
>
|
||||
{fullscreen ? <Minimize size={17} /> : <Maximize size={17} />}
|
||||
{fullscreen ? <Minimize size={theater ? 21 : 17} /> : <Maximize size={theater ? 21 : 17} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +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 } from './playerTheme'
|
||||
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,
|
||||
@@ -56,6 +56,11 @@ type PlayerDanmakuPanelProps = {
|
||||
danmakuInfo?: DanmakuLoadedInfo | null
|
||||
onSelectEpisode: (episodeId: number, animeTitle: string, episodeTitle: string) => void
|
||||
onResetAuto: () => void
|
||||
/**
|
||||
* 竖屏剧场模式:弹幕面板改为视频区底部的动作面板,而不是右侧抽屉,
|
||||
* 窄屏下搜索框与滑杆不再被压扁。
|
||||
*/
|
||||
theater?: boolean
|
||||
}
|
||||
|
||||
export function PlayerDanmakuPanel({
|
||||
@@ -90,6 +95,7 @@ export function PlayerDanmakuPanel({
|
||||
danmakuInfo,
|
||||
onSelectEpisode,
|
||||
onResetAuto,
|
||||
theater = false,
|
||||
}: PlayerDanmakuPanelProps) {
|
||||
const [draft, setDraft] = useState(search)
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||
@@ -201,20 +207,26 @@ export function PlayerDanmakuPanel({
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onPointerUp={(e) => e.stopPropagation()}
|
||||
className={`absolute inset-y-0 right-0 z-30 ${PLAYER_DRAWER}`}
|
||||
className={
|
||||
theater ? PLAYER_SHEET : `absolute inset-y-0 right-0 z-30 ${PLAYER_DRAWER}`
|
||||
}
|
||||
>
|
||||
<div className={PLAYER_PANEL_HEADER}>
|
||||
<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={PLAYER_ICON_BUTTON} title="关闭 (Esc)">
|
||||
<X size={16} />
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={`${PLAYER_ICON_BUTTON} ${theater ? 'h-10 w-10' : ''}`}
|
||||
title="关闭 (Esc)"
|
||||
>
|
||||
<X size={theater ? 20 : 16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-3.5">
|
||||
<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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -27,6 +27,11 @@ type PlayerPlaylistPanelProps = {
|
||||
onSelectEpisode: (media: Media) => void
|
||||
/** 切换到同一集/同一条目的另一个版本。 */
|
||||
onSelectVersion?: (media: Media) => void
|
||||
/**
|
||||
* 竖屏剧场模式:不再以浮层形式盖住播放画面,而是内嵌在视频下方的
|
||||
* 内容区里(外层负责标题与「收起」按钮),选集不再遮挡播放器。
|
||||
*/
|
||||
inline?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,6 +55,7 @@ export function PlayerPlaylistPanel({
|
||||
episodes,
|
||||
onSelectEpisode,
|
||||
onSelectVersion,
|
||||
inline = false,
|
||||
}: PlayerPlaylistPanelProps) {
|
||||
const [filterText, setFilterText] = useState('')
|
||||
/** 正在挑选版本的剧集 id;null = 宫格直接播放。 */
|
||||
@@ -133,19 +139,29 @@ export function PlayerPlaylistPanel({
|
||||
// 舞台用 pointerdown/pointerup 判断「移动端轻触画面」,抽屉上的触摸不该参与。
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onPointerUp={(e) => e.stopPropagation()}
|
||||
className={`absolute inset-y-0 right-0 z-30 ${PLAYER_DRAWER}`}
|
||||
className={
|
||||
inline
|
||||
? 'flex flex-col'
|
||||
: `absolute inset-y-0 right-0 z-30 ${PLAYER_DRAWER}`
|
||||
}
|
||||
>
|
||||
<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>
|
||||
{/* 内嵌在竖屏剧场布局的视频下方时不自带标题栏:外层已经提供了
|
||||
「选集 / 收起」标题,重复一层只会挤掉内容。 */}
|
||||
{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={PLAYER_ICON_BUTTON} title="关闭 (Esc)">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={inline ? undefined : 'flex min-h-0 flex-1 flex-col overflow-hidden'}>
|
||||
|
||||
{/* 当前条目的版本切换:多版本时置顶,随时可换 */}
|
||||
{versionsToSwitch.length > 0 && (
|
||||
@@ -206,7 +222,7 @@ export function PlayerPlaylistPanel({
|
||||
|
||||
{/* 季选择 Tabs(若有多季) */}
|
||||
{hasEpisodeList && seasonGroups.length > 1 && (
|
||||
<div className="no-scrollbar flex shrink-0 items-center gap-1.5 overflow-x-auto border-b border-white/10 px-3 py-2">
|
||||
<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) => mediaVersionMatches(e, currentMediaId))
|
||||
@@ -255,15 +271,21 @@ export function PlayerPlaylistPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 集数宫格 */}
|
||||
{/* 集数宫格:内嵌模式整块跟着外层一起滚动,不再自己滚 */}
|
||||
{hasEpisodeList ? (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-3 select-none">
|
||||
<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="grid grid-cols-5 gap-1.5 sm:grid-cols-6">
|
||||
<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 : []
|
||||
@@ -285,7 +307,7 @@ export function PlayerPlaylistPanel({
|
||||
ref={isPlaying ? activeItemRef : null}
|
||||
onClick={() => onSelectEpisode(ep)}
|
||||
title={cellTitle}
|
||||
className={`flex h-9 w-full items-center justify-center rounded-md text-xs font-medium tabular-nums transition ${
|
||||
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'
|
||||
@@ -308,7 +330,7 @@ export function PlayerPlaylistPanel({
|
||||
current === ep.id ? null : ep.id,
|
||||
)
|
||||
}}
|
||||
className={`absolute right-0 top-0 flex h-3.5 w-3.5 items-center justify-center rounded-bl-md rounded-tr-md text-[9px] font-semibold leading-none transition ${
|
||||
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'
|
||||
@@ -326,11 +348,12 @@ export function PlayerPlaylistPanel({
|
||||
</div>
|
||||
) : (
|
||||
versionsToSwitch.length === 0 && (
|
||||
<div className="flex flex-1 items-center justify-center px-4 text-center text-xs text-white/40">
|
||||
<div className="flex items-center justify-center px-4 py-8 text-center text-xs text-white/40">
|
||||
暂无可切换的内容
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -28,6 +28,27 @@ export const PLAYER_POPOVER =
|
||||
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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
Vendored
+8
@@ -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 {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState, type DragEvent, type MouseEvent, type Reac
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Folder, GripVertical, Image, MoreVertical, Plus, Power, PowerOff, RefreshCw, Save, Trash2 } from 'lucide-react'
|
||||
|
||||
import { imageURL } from '../api/client'
|
||||
import { ARTWORK, imageURL } from '../api/client'
|
||||
import { LocalDirBrowserDialog } from '../components/LocalDirBrowserDialog'
|
||||
import type { Library, LibraryRoot } from '../types'
|
||||
import type { RootDraft } from './adminLibraryPanelModel'
|
||||
@@ -172,7 +172,7 @@ function LibraryTableRow({ library, dragging, dragOver, onDragStart, onDragOver,
|
||||
<div className="flex items-center gap-2">
|
||||
{library.cover_url && (
|
||||
<img
|
||||
src={imageURL(library.cover_url, library.updated_at)}
|
||||
src={imageURL(library.cover_url, library.updated_at, ARTWORK.backdropStrip)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
|
||||
@@ -46,13 +46,13 @@ export function AdminUsersTable({
|
||||
<table className="min-w-[900px] w-full text-left text-sm">
|
||||
<thead className="text-xs uppercase tracking-wider text-sand-500">
|
||||
<tr>
|
||||
<th className="py-2">用户名</th>
|
||||
<th>角色</th>
|
||||
<th>媒体库权限</th>
|
||||
<th>状态</th>
|
||||
<th className="py-2 whitespace-nowrap">用户名</th>
|
||||
<th className="whitespace-nowrap">角色</th>
|
||||
<th className="whitespace-nowrap">媒体库权限</th>
|
||||
<th className="whitespace-nowrap">状态</th>
|
||||
<th>权限说明</th>
|
||||
<th>最近登录</th>
|
||||
<th className="text-right">操作</th>
|
||||
<th className="whitespace-nowrap">最近登录</th>
|
||||
<th className="whitespace-nowrap text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -72,8 +72,8 @@ export function AdminUsersTable({
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-ink-100">{u.role === 'admin' ? '管理员' : '观看用户'}</td>
|
||||
<td>
|
||||
<td className="text-ink-100 whitespace-nowrap">{u.role === 'admin' ? '管理员' : '观看用户'}</td>
|
||||
<td className="whitespace-nowrap">
|
||||
{u.role === 'admin' ? (
|
||||
<span className="inline-flex items-center rounded-full bg-sand-100 px-2.5 py-0.5 text-xs text-sand-600 font-medium">
|
||||
全库 (管理员)
|
||||
@@ -94,13 +94,13 @@ export function AdminUsersTable({
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
<td className={u.is_active ? 'text-green-500' : 'text-red-400'}>
|
||||
<td className={`whitespace-nowrap ${u.is_active ? 'text-green-500' : 'text-red-400'}`}>
|
||||
{u.is_active ? '正常' : '已禁用'}
|
||||
</td>
|
||||
<td className="text-ink-50">
|
||||
<td className="text-ink-50 max-w-[22rem]">
|
||||
{u.role === 'admin' ? '全部管理权限' : '仅浏览/播放/外部播放器,无下载与文件操作'}
|
||||
</td>
|
||||
<td className="text-ink-50">
|
||||
<td className="text-ink-50 whitespace-nowrap">
|
||||
<span className="inline-flex flex-wrap items-center gap-2">
|
||||
<span>{u.last_login_at ? new Date(u.last_login_at).toLocaleString() : '从未登录'}</span>
|
||||
{u.realtime_online && <span className="rounded border border-green-400/40 px-1.5 py-0.5 text-[11px] text-green-500">在线</span>}
|
||||
|
||||
@@ -20,7 +20,7 @@ export function DlnaPage() {
|
||||
dlnaAPI
|
||||
.list(force)
|
||||
.then(setDevices)
|
||||
.catch(() => toast.error('设备发现失败,容器网络可能不支持组播'))
|
||||
.catch(() => toast.error('设备发现失败,容器网络可能不支持组播'))
|
||||
.finally(() => setScanning(false))
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ export function DlnaPage() {
|
||||
{devices.length === 0 && !scanning && (
|
||||
<div className="glass-panel">
|
||||
<p className="text-ink-100">
|
||||
未发现任何 DLNA 设备。请确保:服务器与设备在同一局域网,容器使用 host 网络模式,
|
||||
未发现任何 DLNA 设备。请确保:服务器与设备在同一局域网,容器使用 host 网络模式,
|
||||
目标设备已开启 DLNA / 屏幕镜像。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -74,7 +74,7 @@ export function FavouritesPage() {
|
||||
{items.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
{items.map((m) => (
|
||||
<MediaCard key={m.id} media={m} />
|
||||
<MediaCard key={m.id} media={m} linkState={{ from: '/favourites' }} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
Tv,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { imageURL } from '../api/client'
|
||||
import { ARTWORK, imageURL } from '../api/client'
|
||||
import { useInViewOnce } from '../hooks/useInViewOnce'
|
||||
import { useLazyPreviewBatch } from '../hooks/useLazyPreviewBatch'
|
||||
import { useRememberedListPosition } from '../hooks/useListPositionMemory'
|
||||
@@ -171,7 +171,7 @@ export function HomeCarouselSection({
|
||||
{visual && (
|
||||
<img
|
||||
key={visual + currentItem.id}
|
||||
src={imageURL(visual, currentItem.updated_at, { maxWidth: 1920, maxHeight: 1080, quality: 78 })}
|
||||
src={imageURL(visual, currentItem.updated_at, ARTWORK.backdropHero)}
|
||||
alt=""
|
||||
fetchPriority="high"
|
||||
decoding="async"
|
||||
@@ -283,7 +283,7 @@ export function HomeCarouselSection({
|
||||
</div>
|
||||
{poster && (
|
||||
<img
|
||||
src={imageURL(poster, currentItem.updated_at, { maxWidth: 560, quality: 84 })}
|
||||
src={imageURL(poster, currentItem.updated_at, ARTWORK.posterDetail)}
|
||||
alt={currentItem.title}
|
||||
fetchPriority="high"
|
||||
decoding="async"
|
||||
@@ -516,7 +516,7 @@ export function HomeLibraryCard({
|
||||
src={imageURL(
|
||||
src,
|
||||
version,
|
||||
library.cover_url ? undefined : { maxWidth: 480, maxHeight: 320, quality: 78 },
|
||||
library.cover_url ? undefined : ARTWORK.backdropStrip,
|
||||
)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
@@ -750,7 +750,7 @@ function ContinueCard({ media, progress }: { media: Media; progress: number }) {
|
||||
<div className="relative h-20 w-14 shrink-0 overflow-hidden rounded-xl bg-[var(--app-panel-soft)]">
|
||||
{media.poster_url ? (
|
||||
<img
|
||||
src={imageURL(media.poster_url, media.updated_at, { maxWidth: 180, maxHeight: 240, quality: 78 })}
|
||||
src={imageURL(media.poster_url, media.updated_at, ARTWORK.posterCard)}
|
||||
alt=""
|
||||
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
|
||||
@@ -2,7 +2,7 @@ import { isRemoteEmbyID } from '../utils/remoteEmby'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ArrowLeft, Database, FileText, Film, FolderInput, Pencil, Play, Search, Sparkles, Trash2 } from 'lucide-react'
|
||||
|
||||
import { imageURL } from '../api/client'
|
||||
import { ARTWORK, imageURL } from '../api/client'
|
||||
import { ExternalPlayerButton } from '../components/ExternalPlayerButton'
|
||||
import { MediaFavouriteButton } from '../components/MediaFavouriteButton'
|
||||
import type { Media } from '../types'
|
||||
@@ -70,7 +70,7 @@ export function LibrarySeriesDetailHeader({
|
||||
<div className="w-40 shrink-0 overflow-hidden rounded-xl bg-sand-200 shadow-card">
|
||||
{series.rep.poster_url ? (
|
||||
<img
|
||||
src={imageURL(series.rep.poster_url, series.rep.updated_at)}
|
||||
src={imageURL(series.rep.poster_url, series.rep.updated_at, ARTWORK.posterDetail)}
|
||||
alt={series.rep.title}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Play, Search } from 'lucide-react'
|
||||
|
||||
import { imageURL } from '../api/client'
|
||||
import { ARTWORK, imageURL } from '../api/client'
|
||||
import { ExternalPlayerButton } from '../components/ExternalPlayerButton'
|
||||
import type { Media } from '../types'
|
||||
import {
|
||||
@@ -87,11 +87,7 @@ export function LibrarySeriesEpisodes({
|
||||
>
|
||||
{ep.backdrop_url || ep.poster_url ? (
|
||||
<img
|
||||
src={imageURL(ep.backdrop_url || ep.poster_url || '', ep.updated_at, {
|
||||
maxWidth: 320,
|
||||
maxHeight: 180,
|
||||
quality: 76,
|
||||
})}
|
||||
src={imageURL(ep.backdrop_url || ep.poster_url || '', ep.updated_at, ARTWORK.backdropStrip)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
|
||||
@@ -2,7 +2,7 @@ import { motion } from 'framer-motion'
|
||||
import { FileText, Play } from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import { imageURL } from '../api/client'
|
||||
import { ARTWORK, imageURL } from '../api/client'
|
||||
import type { Media } from '../types'
|
||||
|
||||
type MediaDetailArtworkProps = {
|
||||
@@ -14,11 +14,7 @@ export function MediaDetailBackdrop({ media }: MediaDetailArtworkProps) {
|
||||
<div className="absolute inset-0 h-[480px] z-0 overflow-hidden">
|
||||
{media.backdrop_url || media.poster_url ? (
|
||||
<img
|
||||
src={imageURL(media.backdrop_url || media.poster_url || '', media.updated_at, {
|
||||
maxWidth: 1920,
|
||||
maxHeight: 1080,
|
||||
quality: 76,
|
||||
})}
|
||||
src={imageURL(media.backdrop_url || media.poster_url || '', media.updated_at, ARTWORK.backdropHero)}
|
||||
alt=""
|
||||
decoding="async"
|
||||
fetchPriority="low"
|
||||
@@ -42,11 +38,7 @@ export function MediaDetailPoster({ media }: MediaDetailArtworkProps) {
|
||||
>
|
||||
{media.poster_url ? (
|
||||
<img
|
||||
src={imageURL(media.poster_url, media.updated_at, {
|
||||
maxWidth: 560,
|
||||
maxHeight: 840,
|
||||
quality: 84,
|
||||
})}
|
||||
src={imageURL(media.poster_url, media.updated_at, ARTWORK.posterDetail)}
|
||||
alt={media.title}
|
||||
decoding="async"
|
||||
fetchPriority="high"
|
||||
|
||||
@@ -85,7 +85,7 @@ export function MediaDetailBackButton({ onBack }: { onBack: () => void }) {
|
||||
className="btn-ghost gap-2 bg-white/80 shadow-sm backdrop-blur hover:bg-white"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
<span>返回媒体库</span>
|
||||
<span>返回</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -39,6 +39,8 @@ import { classifyDirectPlayError } from './directPlayError'
|
||||
import { apiErrorMessage } from './StrmManagePage'
|
||||
import { PlayerTopBar } from './PlayerTopBar'
|
||||
import { PlayerVideoStage } from './PlayerVideoStage'
|
||||
import { PlayerMobileTheaterInfo } from '../components/PlayerMobileTheaterInfo'
|
||||
import { useIsMobileTheater } from '../hooks/useIsMobileTheater'
|
||||
import { PlayerDanmakuPanel } from '../components/PlayerDanmakuPanel'
|
||||
import { PlayerPlaylistPanel } from '../components/PlayerPlaylistPanel'
|
||||
import {
|
||||
@@ -197,6 +199,8 @@ export function PlayerPage() {
|
||||
// 选集 / 播放列表状态
|
||||
const [playlistEpisodes, setPlaylistEpisodes] = useState<Media[]>([])
|
||||
const [playlistOpen, setPlaylistOpen] = useState(false)
|
||||
// 竖屏剧场模式:每次从操作栏点「选集」都 +1,把视频下方的选集区滚回视野。
|
||||
const [episodeRevealToken, setEpisodeRevealToken] = useState(0)
|
||||
|
||||
const teardownHls = useCallback(() => {
|
||||
if (hlsRef.current) {
|
||||
@@ -1257,6 +1261,16 @@ export function PlayerPage() {
|
||||
if (next) setDanmakuOpen(false)
|
||||
return next
|
||||
})
|
||||
setEpisodeRevealToken((token) => token + 1)
|
||||
}, [])
|
||||
|
||||
// 操作栏 / 设置面板里的「选集」入口。竖屏剧场模式下选集区就在视频下方,
|
||||
// 这里只负责展开并滚过去,不把已经展开的列表收起来(收起由选集区自己的
|
||||
// 「收起」按钮负责),这样播放画面永远不会被浮层挡住。
|
||||
const openPlaylistEntry = useCallback(() => {
|
||||
setPlaylistOpen(true)
|
||||
setDanmakuOpen(false)
|
||||
setEpisodeRevealToken((token) => token + 1)
|
||||
}, [])
|
||||
|
||||
const toggleDanmakuOpen = useCallback(() => {
|
||||
@@ -1525,6 +1539,13 @@ export function PlayerPage() {
|
||||
setHlsSource('local')
|
||||
setCloudWaiting(false)
|
||||
setCloudWaitMessage('')
|
||||
// 本地档位不可用(宿主机没有可用的 ffmpeg)时不要进入必然失败的 HLS:
|
||||
// 保持直连播放并说明怎么修,避免播放器先打一个 500 再回退。
|
||||
if (!quality.available) {
|
||||
setPlaybackMode('direct')
|
||||
toast.error(quality.note || '当前无法进行 HLS 转码,请在「系统设置 → 常规」安装 ffmpeg 后重试')
|
||||
return
|
||||
}
|
||||
if (position > 2) setHlsStartSec(position)
|
||||
setPlaybackMode('hls')
|
||||
},
|
||||
@@ -1655,6 +1676,10 @@ export function PlayerPage() {
|
||||
media?.title?.trim() ||
|
||||
''
|
||||
|
||||
// 竖屏手机走剧场布局:视频贴顶按 16:9 自适应,下方是标题/选集/简介。
|
||||
// 桌面端与横屏手机保持原来的全屏居中沉浸式布局。
|
||||
const isMobileTheater = useIsMobileTheater()
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-full flex-1 flex-col overflow-hidden bg-black">
|
||||
<PlayerTopBar
|
||||
@@ -1667,6 +1692,7 @@ export function PlayerPage() {
|
||||
onBack={goBack}
|
||||
/>
|
||||
<PlayerVideoStage
|
||||
theater={isMobileTheater}
|
||||
media={media}
|
||||
loadError={loadError}
|
||||
playerError={playerError}
|
||||
@@ -1708,7 +1734,7 @@ export function PlayerPage() {
|
||||
playlistOpen={playlistOpen}
|
||||
hasPlaylist={playlistEpisodes.length > 1 || currentVersions.length > 1}
|
||||
hasVersions={currentVersions.length > 1}
|
||||
onTogglePlaylist={togglePlaylistOpen}
|
||||
onTogglePlaylist={isMobileTheater ? openPlaylistEntry : togglePlaylistOpen}
|
||||
knownDuration={media?.duration_sec || 0}
|
||||
streamOffset={mode === 'hls' && hlsSource === 'local' ? hlsStartSec : 0}
|
||||
onSeekAbsolute={mode === 'hls' && hlsSource === 'local' ? handleSeekAbsolute : undefined}
|
||||
@@ -1729,18 +1755,21 @@ export function PlayerPage() {
|
||||
showVr360Guide={Boolean(vr360) && vr360GuideSeen === false}
|
||||
onDismissVr360Guide={dismissVr360Guide}
|
||||
playlistPanel={
|
||||
<PlayerPlaylistPanel
|
||||
open={playlistOpen}
|
||||
onClose={() => setPlaylistOpen(false)}
|
||||
currentMediaId={media?.id ?? ''}
|
||||
currentVersions={currentVersions}
|
||||
episodes={playlistEpisodes}
|
||||
onSelectEpisode={playEpisode}
|
||||
onSelectVersion={switchVersion}
|
||||
/>
|
||||
isMobileTheater
|
||||
? undefined
|
||||
: <PlayerPlaylistPanel
|
||||
open={playlistOpen}
|
||||
onClose={() => setPlaylistOpen(false)}
|
||||
currentMediaId={media?.id ?? ''}
|
||||
currentVersions={currentVersions}
|
||||
episodes={playlistEpisodes}
|
||||
onSelectEpisode={playEpisode}
|
||||
onSelectVersion={switchVersion}
|
||||
/>
|
||||
}
|
||||
danmakuPanel={
|
||||
<PlayerDanmakuPanel
|
||||
theater={isMobileTheater}
|
||||
open={danmakuOpen}
|
||||
onClose={() => setDanmakuOpen(false)}
|
||||
enabled={danmakuEnabled}
|
||||
@@ -1775,6 +1804,36 @@ export function PlayerPage() {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{isMobileTheater ? (
|
||||
<PlayerMobileTheaterInfo
|
||||
media={media?.id === id ? media : null}
|
||||
title={topBarTitle}
|
||||
subtitle={topBarSubtitle}
|
||||
playbackModeLabel={playbackModeLabel}
|
||||
qualityLabel={showQuality ? qualityLabel : undefined}
|
||||
episodes={playlistEpisodes}
|
||||
currentMediaId={media?.id ?? ''}
|
||||
currentEpisodeIndex={currentEpisodeIndex}
|
||||
currentVersions={currentVersions}
|
||||
onSelectEpisode={playEpisode}
|
||||
onSelectVersion={switchVersion}
|
||||
playlistOpen={playlistOpen}
|
||||
onTogglePlaylist={togglePlaylistOpen}
|
||||
revealToken={episodeRevealToken}
|
||||
playlistPanel={
|
||||
<PlayerPlaylistPanel
|
||||
inline
|
||||
open={playlistOpen}
|
||||
onClose={() => setPlaylistOpen(false)}
|
||||
currentMediaId={media?.id ?? ''}
|
||||
currentVersions={currentVersions}
|
||||
episodes={playlistEpisodes}
|
||||
onSelectEpisode={playEpisode}
|
||||
onSelectVersion={switchVersion}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -156,6 +156,11 @@ function buildSubtitleRenderGroups(
|
||||
|
||||
type PlayerVideoStageProps = {
|
||||
media: Media | null
|
||||
/**
|
||||
* 竖屏剧场模式:舞台不再全屏居中,而是贴顶按 16:9 自适应高度;
|
||||
* 弹层改为底部动作面板,避免右侧抽屉与悬浮小弹层在窄屏下被裁剪。
|
||||
*/
|
||||
theater?: boolean
|
||||
/** 媒体元数据加载失败提示(非空时替代「加载中」展示)。 */
|
||||
loadError?: string
|
||||
playerError: string
|
||||
@@ -234,6 +239,7 @@ type PlayerVideoStageProps = {
|
||||
|
||||
export function PlayerVideoStage({
|
||||
media,
|
||||
theater = false,
|
||||
loadError,
|
||||
playerError,
|
||||
subs,
|
||||
@@ -725,12 +731,22 @@ export function PlayerVideoStage({
|
||||
const assTrack = subtitleIndex >= 0 ? subs[subtitleIndex] : undefined
|
||||
// VR 模式下画面由 WebGL 画布输出,舞台窗口本身就是「镜头」,
|
||||
// 不再按视频宽高比留黑边。
|
||||
//
|
||||
// 竖屏剧场模式(theater):舞台不再全屏居中,视频贴顶按容器宽度
|
||||
// 16:9 自适应高度,下方留给标题/选集/简介内容区。stage 本体的
|
||||
// 高度只包住视频区,控制栏与弹层都相对这块区域定位。
|
||||
const wrapperStyle = vr360
|
||||
? {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}
|
||||
: videoRatio
|
||||
: theater
|
||||
? {
|
||||
aspectRatio: '16 / 9',
|
||||
width: '100%',
|
||||
maxHeight: '100%',
|
||||
}
|
||||
: videoRatio
|
||||
? {
|
||||
aspectRatio: `${videoRatio}`,
|
||||
width: isWiderThanStage ? '100%' : 'auto',
|
||||
@@ -747,7 +763,11 @@ export function PlayerVideoStage({
|
||||
<div
|
||||
ref={stageRef}
|
||||
data-player-stage
|
||||
className="relative flex h-full w-full flex-1 items-center justify-center overflow-hidden bg-black"
|
||||
className={
|
||||
theater
|
||||
? 'relative flex w-full shrink-0 items-start justify-center overflow-hidden bg-black'
|
||||
: 'relative flex h-full w-full flex-1 items-center justify-center overflow-hidden bg-black'
|
||||
}
|
||||
onPointerDown={handleStagePointerDown}
|
||||
onPointerMove={handleStagePointerMove}
|
||||
onMouseLeave={() => setLockHovered(false)}
|
||||
@@ -851,6 +871,7 @@ export function PlayerVideoStage({
|
||||
) : null}
|
||||
</div>
|
||||
<PlayerControls
|
||||
theater={theater}
|
||||
videoRef={videoRef}
|
||||
volume={playerVolume}
|
||||
onVolumeChange={onPlayerVolumeChange}
|
||||
|
||||
@@ -154,7 +154,7 @@ export function ProfileManagementPage() {
|
||||
<div className="glass-panel py-12 text-center">
|
||||
<div className="mb-2 text-4xl">👤</div>
|
||||
<p className="font-medium text-ink-600">暂无 Profile</p>
|
||||
<p className="text-sm text-ink-50">点击右上角"创建 Profile"开始</p>
|
||||
<p className="text-sm text-ink-50">点击右上角「创建 Profile」开始</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { imageURL } from '../api/client'
|
||||
import { ARTWORK, imageURL } from '../api/client'
|
||||
import { scraperAPI } from '../api/scraper'
|
||||
import type { ScrapeQueueSnapshot, ScrapeTask, ScrapeTaskStatus } from '../types/scraper'
|
||||
import { apiErrorMessage, formatTime, taskStatusMeta } from './StrmManagePage'
|
||||
@@ -756,7 +756,7 @@ export function ScraperQueuePage({ embedded = false }: { embedded?: boolean }) {
|
||||
<div className="flex items-center gap-2">
|
||||
{task.poster_url ? (
|
||||
<img
|
||||
src={imageURL(task.poster_url)}
|
||||
src={imageURL(task.poster_url, undefined, ARTWORK.posterTiny)}
|
||||
alt=""
|
||||
className="h-10 w-7 rounded object-cover border border-gray-200 shrink-0"
|
||||
onError={(e) => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Clock, Play, Trash2 } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { historyAPI } from '../api/history'
|
||||
import { imageURL } from '../api/client'
|
||||
import { ARTWORK, imageURL } from '../api/client'
|
||||
import { confirmAction } from '../components/confirmAction'
|
||||
import { isRemoteEmbyID } from '../utils/remoteEmby'
|
||||
import type { HistoryItem, Media } from '../types'
|
||||
@@ -117,7 +117,7 @@ export function WatchHistoryPage() {
|
||||
<div className="h-16 w-12 shrink-0 overflow-hidden rounded-lg bg-surface-900">
|
||||
{m.poster_url ? (
|
||||
<img
|
||||
src={imageURL(m.poster_url, m.updated_at)}
|
||||
src={imageURL(m.poster_url, m.updated_at, ARTWORK.posterCard)}
|
||||
alt={m.title}
|
||||
className="h-full w-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
@@ -127,6 +127,7 @@ export function WatchHistoryPage() {
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<Link
|
||||
to={`/media/${m.id}`}
|
||||
state={{ from: '/history' }}
|
||||
className="block truncate font-medium text-ink-600 transition hover:text-brand-500"
|
||||
>
|
||||
{m.title}
|
||||
@@ -150,6 +151,7 @@ export function WatchHistoryPage() {
|
||||
<div className="flex w-full items-center gap-2 sm:w-auto sm:shrink-0">
|
||||
<Link
|
||||
to={`/play/${m.id}`}
|
||||
state={{ from: '/history' }}
|
||||
className="neon-button !px-3 !py-1 !text-xs"
|
||||
>
|
||||
<Play size={12} /> 继续
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { AlertCircle, Ban, Copy, ExternalLink, RefreshCw, Sparkles, Trash2, X } from 'lucide-react'
|
||||
|
||||
import { imageURL } from '../../api/client'
|
||||
import { ARTWORK, imageURL } from '../../api/client'
|
||||
import type { ScrapeTask } from '../../types/scraper'
|
||||
import { formatTime, taskStatusMeta } from '../StrmManagePage'
|
||||
import { PROVIDER_LABELS, TYPE_LABELS } from './scrapeLabels'
|
||||
@@ -52,7 +52,7 @@ export function ScrapeDetailModal({
|
||||
<div className="flex gap-4 rounded-2xl border border-brand-500/20 bg-primary-400/5 p-4">
|
||||
{task.poster_url && (
|
||||
<img
|
||||
src={imageURL(task.poster_url)}
|
||||
src={imageURL(task.poster_url, undefined, ARTWORK.posterTiny)}
|
||||
alt=""
|
||||
className="h-28 w-20 rounded-xl object-cover border border-brand-500/30 shadow-md shrink-0"
|
||||
/>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user