mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 03:06:38 +08:00
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 474513f18a | |||
| 0810bfb234 | |||
| 61a7260eaf | |||
| 45f4763b0c | |||
| f4f57759ce | |||
| 6ec9adc976 | |||
| f4abdf78e2 | |||
| ae8db26c1e | |||
| 8017e994f4 | |||
| d56fb53a08 | |||
| 30ca94c79b | |||
| 9c1f9a6e7e | |||
| ec7be38266 | |||
| ebf791c436 | |||
| 83c0b65a0b | |||
| 1153fe4191 | |||
| 93866d169b | |||
| d283d39008 | |||
| fc89d23867 | |||
| 1f14a1e85b | |||
| f0a6b630c6 | |||
| c723664e46 | |||
| 5faa6975d0 | |||
| cbfae0252e | |||
| 57ee482146 | |||
| a9b3110b2c | |||
| ff86c2f597 | |||
| a5731fa963 | |||
| 531e45f677 | |||
| cd3f028f0b | |||
| bd7b7a722b | |||
| 83deffcf45 | |||
| c74f6be250 | |||
| c911a57467 | |||
| 1dbc3f8fe7 | |||
| a563ee11d6 |
@@ -0,0 +1,2 @@
|
||||
'Get-Content' is not recognized as an internal or external command,
|
||||
operable program or batch file.
|
||||
@@ -0,0 +1,216 @@
|
||||
# MeBox + MetaTube 一体部署 Docker Compose 部署文件
|
||||
#
|
||||
# 组件:
|
||||
# MeBox + PostgreSQL + Redis + MetaTube
|
||||
#
|
||||
# 与 docker-compose.standard.yml 的区别:
|
||||
# 额外内置一个 MetaTube 后端(番号元数据刮削 / 封面人脸裁剪),
|
||||
# 并与 MeBox 共用同一个 PostgreSQL 实例,不需要再单独起一套数据库。
|
||||
#
|
||||
# 使用方式二选一:
|
||||
# 1. 保存为 docker-compose.yml 后执行:
|
||||
# docker compose up -d
|
||||
# 2. 保留本文件名时执行:
|
||||
# docker compose -f docker-compose.metatube.yml up -d
|
||||
#
|
||||
# 首次启动后需要做一次配置(只有这一处是手动的):
|
||||
# 登录 MeBox → 设置 → 番号刮削:
|
||||
# - 番号刮削引擎:MetaTube 后端服务,或 智能混合
|
||||
# - MetaTube 服务端地址:http://metatube:8080
|
||||
# - 访问 Token:留空(仅内部网络,未启用认证;如需认证两边填同一个值)
|
||||
#
|
||||
# 说明:
|
||||
# - MetaTube 使用独立数据库 metatube,由 metatube-db-init 一次性容器自动创建;
|
||||
# 和 MeBox 的 mebox 库共用同一个 PostgreSQL 实例,只是分成两个库。
|
||||
# - MetaTube 不向宿主机映射端口,只在本 Compose 内部网络提供服务。
|
||||
# - MeBox 故意不依赖 MetaTube:MetaTube 未就绪或挂掉时,智能混合模式会自动回退到内置刮削源。
|
||||
# - 更新全部组件:
|
||||
# docker compose -f docker-compose.metatube.yml pull
|
||||
# docker compose -f docker-compose.metatube.yml up -d
|
||||
# 保留本文件名时,管理面板「系统更新」需要把「Compose 安装目录」指向本文件所在目录,
|
||||
# 或把更新命令改成带 -f docker-compose.metatube.yml 的形式。
|
||||
#
|
||||
# 默认账号:
|
||||
# admin / admin123
|
||||
|
||||
services:
|
||||
mebox:
|
||||
image: ghcr.io/truewhile/mebox:latest
|
||||
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
ports:
|
||||
- "18080:8080"
|
||||
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
||||
volumes:
|
||||
# 程序运行数据:JWT 密钥、运行配置、旧 SQLite 迁移源。
|
||||
- ./data:/data
|
||||
|
||||
# 缓存目录:海报缓存、临时文件等。通常不用备份。
|
||||
- ./cache:/cache
|
||||
|
||||
# 媒体库目录。自动整理/重命名/入库需要读写权限。
|
||||
# NAS 示例:source: /vol1/1000/Media
|
||||
# Windows Docker Desktop 示例:source: D:/Media
|
||||
# create_host_path=false 可以避免路径写错时 Docker 自动创建空文件夹。
|
||||
- ./media: /media
|
||||
|
||||
environment:
|
||||
TZ: Asia/Shanghai
|
||||
|
||||
PUID: "0"
|
||||
PGID: "0"
|
||||
|
||||
MEBOX_APP_HOST: 0.0.0.0
|
||||
MEBOX_APP_PORT: 8080
|
||||
MEBOX_APP_WEB_DIR: /app/web/dist
|
||||
MEBOX_APP_DATA_DIR: /data
|
||||
MEBOX_LOGGING_LEVEL: info
|
||||
MEBOX_LOGGING_FORMAT: console
|
||||
MEBOX_LOGGING_OUTPUT_PATH: /data/logs
|
||||
MEBOX_LOGGING_MAX_SIZE_MB: "20"
|
||||
MEBOX_LOGGING_MAX_BACKUPS: "5"
|
||||
MEBOX_LOGGING_MAX_AGE_DAYS: "30"
|
||||
|
||||
MEBOX_DATABASE_TYPE: postgres
|
||||
MEBOX_DATABASE_DSN: postgres://mebox:mebox@postgres:5432/mebox?sslmode=disable
|
||||
MEBOX_DATABASE_DB_PATH: /data/mebox.db
|
||||
|
||||
# Redis 只做热缓存,源数据仍在 PostgreSQL;Redis 丢失可自动重建。
|
||||
MEBOX_CACHE_REDIS_URL: redis://redis:6379/0
|
||||
MEBOX_CACHE_CACHE_DIR: /cache
|
||||
|
||||
MEBOX_UPDATE_IMAGE: ghcr.io/truewhile/mebox:latest
|
||||
|
||||
# 路径换算配置。左边宿主机真实路径要和 volumes 左边保持一致。
|
||||
MEBOX_MEDIA_DIR: /media
|
||||
MEBOX_MEDIA_CONTAINER_DIR: /media
|
||||
MEBOX_DOWNLOAD_DIR: /downloads
|
||||
MEBOX_DOWNLOAD_CONTAINER_DIR: /downloads
|
||||
|
||||
MEBOX_TRANSCODER_ENABLED: "true"
|
||||
MEBOX_TRANSCODER_HARDWARE_ACCEL: "false"
|
||||
MEBOX_TRANSCODER_REALTIME: "true"
|
||||
MEBOX_TRANSCODER_THREADS: "2"
|
||||
MEBOX_TRANSCODER_MAX_CONCURRENT: "1"
|
||||
MEBOX_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120"
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "20m"
|
||||
max-file: "3"
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
# 首次部署允许拉取;日常更新请只 pull mebox。
|
||||
# MetaTube 与 MeBox 共用本实例(不同数据库),无需第二套 PostgreSQL。
|
||||
pull_policy: missing
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: mebox
|
||||
POSTGRES_USER: mebox
|
||||
POSTGRES_PASSWORD: mebox
|
||||
TZ: Asia/Shanghai
|
||||
volumes:
|
||||
# 同时存放 mebox 与 metatube 两个数据库,备份这一个目录即可。
|
||||
- ./postgres:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U mebox -d mebox"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "20m"
|
||||
max-file: "3"
|
||||
|
||||
# 一次性容器:确保 metatube 库存在。
|
||||
# MetaTube 自己的 -db-auto-migrate 只建表不建库,所以必须先建库。
|
||||
# 数据库已存在时它会直接退出,重复执行无副作用。
|
||||
metatube-db-init:
|
||||
image: postgres:16-alpine
|
||||
pull_policy: missing
|
||||
restart: "no"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
PGPASSWORD: mebox
|
||||
entrypoint: ["/bin/sh", "-c"]
|
||||
command:
|
||||
- |
|
||||
psql -h postgres -U mebox -d mebox -tAc "SELECT 1 FROM pg_database WHERE datname='metatube'" | grep -q 1 || psql -h postgres -U mebox -d mebox -c "CREATE DATABASE metatube OWNER mebox"
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "5m"
|
||||
max-file: "1"
|
||||
|
||||
metatube:
|
||||
image: ghcr.io/metatube-community/metatube-server:latest
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
metatube-db-init:
|
||||
condition: service_completed_successfully
|
||||
environment:
|
||||
TZ: Asia/Shanghai
|
||||
# 需要走代理时在这里填写,例如 http://192.168.1.2:7890
|
||||
HTTP_PROXY: ""
|
||||
HTTPS_PROXY: ""
|
||||
# 不需要 ports:MeBox 通过内部网络访问 http://metatube:8080。
|
||||
# 只有想让局域网内其他工具直连时才映射,且建议只映射到 127.0.0.1。
|
||||
command:
|
||||
- -dsn
|
||||
- postgres://mebox:mebox@postgres:5432/metatube?sslmode=disable
|
||||
- -port
|
||||
- "8080"
|
||||
- -db-auto-migrate
|
||||
- -db-prepared-stmt
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "20m"
|
||||
max-file: "3"
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
pull_policy: missing
|
||||
restart: unless-stopped
|
||||
command:
|
||||
- redis-server
|
||||
- --appendonly
|
||||
- "yes"
|
||||
- --maxmemory
|
||||
- 256mb
|
||||
- --maxmemory-policy
|
||||
- allkeys-lru
|
||||
volumes:
|
||||
- ./redis:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "20m"
|
||||
max-file: "3"
|
||||
@@ -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`(`internal/service/cache_cleanup.go`) | 现成的「按总大小做 LRU 淘汰」助手,书籍缓存淘汰直接复用它 |
|
||||
| `service/scheduler_local_jobs.go` | 本地定时任务的挂载点,书籍缓存清理与每日统计汇总都注册在这里 |
|
||||
| `config.CacheConfig`(`internal/config/types.go`) | 已有 `CacheDir` / `MaxDiskUsageMB` / `TTLHours` / `AutoCleanup` / `CleanupIntervalMin`,书籍缓存容量配置直接挂进去 |
|
||||
| `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 数组
|
||||
```
|
||||
@@ -112,7 +112,8 @@ func TestGetDanmakuConfigIncludesPerUserMergePreference(t *testing.T) {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
var cfg struct {
|
||||
MergeSources bool `json:"merge_sources"`
|
||||
MergeSources bool `json:"merge_sources"`
|
||||
PlaybackRate float64 `json:"playback_rate"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
@@ -120,6 +121,9 @@ func TestGetDanmakuConfigIncludesPerUserMergePreference(t *testing.T) {
|
||||
if cfg.MergeSources {
|
||||
t.Fatal("default merge preference should be false")
|
||||
}
|
||||
if cfg.PlaybackRate != 1 {
|
||||
t.Fatalf("default playback rate = %v, want 1", cfg.PlaybackRate)
|
||||
}
|
||||
|
||||
if err := svc.Danmaku.SetMergeSources(t.Context(), "user-1", true); err != nil {
|
||||
t.Fatalf("set: %v", err)
|
||||
@@ -137,7 +141,7 @@ func TestGetDanmakuConfigIncludesPerUserMergePreference(t *testing.T) {
|
||||
func TestUpdateDanmakuSettingsPersistsAllPlayerPreferences(t *testing.T) {
|
||||
svc := newDanmakuSettingsService(t)
|
||||
|
||||
body := "{\"enabled\":false,\"opacity\":0.6,\"font_size\":32,\"area\":0.7,\"merge_sources\":true,\"volume\":0.35,\"source\":\"https://dm.example/base/\",\"app_id\":\"my-app-id\",\"app_key\":\"my-app-secret\"}"
|
||||
body := "{\"enabled\":false,\"opacity\":0.6,\"font_size\":32,\"area\":0.7,\"merge_sources\":true,\"volume\":0.35,\"playback_rate\":1.5,\"source\":\"https://dm.example/base/\",\"app_id\":\"my-app-id\",\"app_key\":\"my-app-secret\"}"
|
||||
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings", body, "user-1")
|
||||
updateDanmakuSettingsHandler(svc)(c)
|
||||
|
||||
@@ -154,7 +158,7 @@ func TestUpdateDanmakuSettingsPersistsAllPlayerPreferences(t *testing.T) {
|
||||
if cfg.Enabled || cfg.Opacity != "0.6" || cfg.FontSize != "32" || cfg.Area != "0.7" {
|
||||
t.Fatalf("unexpected render config: %+v", cfg)
|
||||
}
|
||||
if !cfg.MergeSources || cfg.Volume != 0.35 {
|
||||
if !cfg.MergeSources || cfg.Volume != 0.35 || cfg.PlaybackRate != 1.5 {
|
||||
t.Fatalf("unexpected user preferences: %+v", cfg)
|
||||
}
|
||||
if cfg.Source != "https://dm.example/base" || cfg.AppID != "my-app-id" || !cfg.AppKeyConfigured {
|
||||
@@ -165,11 +169,63 @@ func TestUpdateDanmakuSettingsPersistsAllPlayerPreferences(t *testing.T) {
|
||||
if err != nil || user == nil {
|
||||
t.Fatalf("read persisted user: %v", err)
|
||||
}
|
||||
if user.DanmakuAppKey != "my-app-secret" || user.PlayerVolume != 0.35 || user.DanmakuSource != "https://dm.example/base" {
|
||||
if user.DanmakuAppKey != "my-app-secret" || user.PlayerVolume != 0.35 || user.PlayerPlaybackRate != 1.5 || user.DanmakuSource != "https://dm.example/base" {
|
||||
t.Fatalf("preferences not persisted: %+v", user)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDanmakuSettingsPersistsVr360GuideSeen(t *testing.T) {
|
||||
svc := newDanmakuSettingsService(t)
|
||||
|
||||
// 默认未看过:配置接口必须先如实告知前端,否则首次操作说明会被跳过。
|
||||
initial := svc.Danmaku.ConfigForUser(t.Context(), "user-1")
|
||||
if initial.Vr360GuideSeen {
|
||||
t.Fatal("vr360 guide should be unread for a fresh user")
|
||||
}
|
||||
|
||||
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings",
|
||||
`{"vr360_guide_seen":true}`, "user-1")
|
||||
updateDanmakuSettingsHandler(svc)(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
var cfg service.DanmakuRenderConfig
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if !cfg.Vr360GuideSeen {
|
||||
t.Fatalf("config should reflect the persisted guide flag: %+v", cfg)
|
||||
}
|
||||
|
||||
user, err := svc.Repo.User.FindByID(t.Context(), "user-1")
|
||||
if err != nil || user == nil {
|
||||
t.Fatalf("read persisted user: %v", err)
|
||||
}
|
||||
if !user.PlayerVr360GuideSeen {
|
||||
t.Fatalf("guide flag not persisted on the user: %+v", user)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDanmakuSettingsRejectsInvalidPlaybackRate(t *testing.T) {
|
||||
svc := newDanmakuSettingsService(t)
|
||||
|
||||
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings",
|
||||
`{"playback_rate":4}`, "user-1")
|
||||
updateDanmakuSettingsHandler(svc)(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400 (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
user, err := svc.Repo.User.FindByID(t.Context(), "user-1")
|
||||
if err != nil || user == nil {
|
||||
t.Fatalf("read user: %v", err)
|
||||
}
|
||||
if user.PlayerPlaybackRate != 1 {
|
||||
t.Fatalf("invalid playback rate was persisted: %v", user.PlayerPlaybackRate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDanmakuSettingsRejectsInvalidSource(t *testing.T) {
|
||||
svc := newDanmakuSettingsService(t)
|
||||
|
||||
|
||||
@@ -26,6 +26,13 @@ func embyUserID(c *gin.Context) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// embyEffectiveUserID 返回当前鉴权用户。收藏、续播、播放进度等用户态数据必须
|
||||
// 绑定到登录身份;路径/Query 里的 UserId 只是 Emby URL 形态兼容,不能覆盖鉴权结果,
|
||||
// 否则多用户会串到同一个 UserId 上。
|
||||
func embyEffectiveUserID(c *gin.Context) string {
|
||||
return embyUserID(c)
|
||||
}
|
||||
|
||||
const embyCompatSessionTTL = 30 * time.Minute
|
||||
|
||||
type embyCompatSession struct {
|
||||
|
||||
@@ -13,13 +13,7 @@ import (
|
||||
func parseEmbyItemsParams(c *gin.Context) service.ItemsParams {
|
||||
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "50"))
|
||||
offset, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "StartIndex", "startIndex", "startindex"), "0"))
|
||||
uid := c.Param("userId")
|
||||
if uid == "" {
|
||||
uid = firstQueryValue(c, "UserId", "userId", "userid")
|
||||
}
|
||||
if uid == "" {
|
||||
uid = embyUserID(c)
|
||||
}
|
||||
uid := embyEffectiveUserID(c)
|
||||
splitOpt := func(s string) []string {
|
||||
if s == "" {
|
||||
return nil
|
||||
@@ -46,9 +40,28 @@ func parseEmbyItemsParams(c *gin.Context) service.ItemsParams {
|
||||
SortOrder: firstQueryValue(c, "SortOrder", "sortOrder", "sortorder"),
|
||||
Limit: limit,
|
||||
StartIndex: offset,
|
||||
SeasonIndex: parseEmbySeasonIndexQuery(c),
|
||||
}
|
||||
}
|
||||
|
||||
// parseEmbySeasonIndexQuery 读取客户端请求的季序号。
|
||||
//
|
||||
// Emby 客户端有两种表达方式:SeasonId(虚拟季 ID)与 Season / SeasonIndex
|
||||
// (季序号,特别篇为 0)。两者都是合法入参,SeasonId 更精确。这里只解析季序号,
|
||||
// 返回 nil 表示客户端没有按季过滤(区别于 Season=0 的特别篇)。
|
||||
func parseEmbySeasonIndexQuery(c *gin.Context) *int {
|
||||
raw := firstQueryValue(c, "Season", "season", "SeasonIndex", "seasonIndex", "seasonindex")
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
value, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
// 客户端偶尔传入季名称之类的非数字值;按「未过滤」处理,避免整季空结果。
|
||||
return nil
|
||||
}
|
||||
return &value
|
||||
}
|
||||
|
||||
func embyFirstNonEmptyString(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
@@ -73,10 +86,7 @@ func embyItemsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
func embyItemByIDHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
uid := c.Param("userId")
|
||||
if uid == "" {
|
||||
uid = embyUserID(c)
|
||||
}
|
||||
uid := embyEffectiveUserID(c)
|
||||
out, err := svc.Emby.Item(c.Request.Context(), id, uid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
@@ -106,13 +116,7 @@ func embyUserItemByIDHandler(svc *service.Container) gin.HandlerFunc {
|
||||
|
||||
func embyLatestItemsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid := c.Param("userId")
|
||||
if uid == "" {
|
||||
uid = firstQueryValue(c, "UserId", "userId", "userid")
|
||||
}
|
||||
if uid == "" {
|
||||
uid = embyUserID(c)
|
||||
}
|
||||
uid := embyEffectiveUserID(c)
|
||||
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "20"))
|
||||
out, err := svc.Emby.LatestItems(c.Request.Context(), uid, firstQueryValue(c, "ParentId", "parentId", "parentid"), limit)
|
||||
if err != nil {
|
||||
@@ -126,13 +130,7 @@ func embyLatestItemsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
|
||||
func embyResumeItemsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid := c.Param("userId")
|
||||
if uid == "" {
|
||||
uid = firstQueryValue(c, "UserId", "userId", "userid")
|
||||
}
|
||||
if uid == "" {
|
||||
uid = embyUserID(c)
|
||||
}
|
||||
uid := embyEffectiveUserID(c)
|
||||
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "20"))
|
||||
out, err := svc.Emby.ResumeItems(c.Request.Context(), uid, limit)
|
||||
if err != nil {
|
||||
@@ -147,13 +145,7 @@ func embyResumeItemsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
func embyItemsCountsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if svc != nil && svc.Emby != nil {
|
||||
uid := firstQueryValue(c, "UserId", "userId")
|
||||
if uid == "" {
|
||||
uid = c.Param("userId")
|
||||
}
|
||||
if uid == "" {
|
||||
uid = embyUserID(c)
|
||||
}
|
||||
uid := embyEffectiveUserID(c)
|
||||
out, err := svc.Emby.ItemCounts(c.Request.Context(), uid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
@@ -209,7 +201,7 @@ func embySaveDisplayPreferencesHandler(_ *service.Container) gin.HandlerFunc {
|
||||
func embyShowSeasonsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
params := service.ItemsParams{
|
||||
UserID: firstQueryValue(c, "UserId", "userId"),
|
||||
UserID: embyEffectiveUserID(c),
|
||||
ParentID: c.Param("id"),
|
||||
Limit: 500,
|
||||
}
|
||||
@@ -226,15 +218,23 @@ 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: firstQueryValue(c, "UserId", "userId"),
|
||||
UserID: embyEffectiveUserID(c),
|
||||
ParentID: parentID,
|
||||
IncludeItemTypes: []string{"Episode"},
|
||||
Recursive: true,
|
||||
Limit: 500,
|
||||
SeasonIndex: seasonIndex,
|
||||
}
|
||||
out, err := svc.Emby.Items(c.Request.Context(), params)
|
||||
if err != nil {
|
||||
|
||||
@@ -13,10 +13,7 @@ import (
|
||||
|
||||
func embyPlaybackInfoHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid := c.Param("userId")
|
||||
if uid == "" {
|
||||
uid = embyUserID(c)
|
||||
}
|
||||
uid := embyEffectiveUserID(c)
|
||||
out, err := svc.Emby.PlaybackInfo(c.Request.Context(), c.Param("id"), uid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
@@ -47,10 +44,7 @@ func embySubtitleStreamHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
return
|
||||
}
|
||||
uid := c.Param("userId")
|
||||
if uid == "" {
|
||||
uid = embyUserID(c)
|
||||
}
|
||||
uid := embyEffectiveUserID(c)
|
||||
ctx := c.Request.Context()
|
||||
// The official-format route carries a :format suffix (Stream.ass /
|
||||
// Stream.vtt); prefer it for the Content-Type when present, otherwise
|
||||
|
||||
@@ -92,10 +92,7 @@ func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
|
||||
|
||||
func embyFavoriteHandler(svc *service.Container, fav bool) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid := c.Param("userId")
|
||||
if uid == "" {
|
||||
uid = embyUserID(c)
|
||||
}
|
||||
uid := embyEffectiveUserID(c)
|
||||
mid := c.Param("itemId")
|
||||
if uid == "" || mid == "" {
|
||||
c.Status(http.StatusBadRequest)
|
||||
@@ -116,10 +113,7 @@ func embyFavoriteHandler(svc *service.Container, fav bool) gin.HandlerFunc {
|
||||
|
||||
func embyMarkPlayedHandler(svc *service.Container, played bool) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid := c.Param("userId")
|
||||
if uid == "" {
|
||||
uid = embyUserID(c)
|
||||
}
|
||||
uid := embyEffectiveUserID(c)
|
||||
mid := c.Param("itemId")
|
||||
if uid == "" || mid == "" {
|
||||
c.Status(http.StatusBadRequest)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -10,10 +10,7 @@ import (
|
||||
|
||||
func embyViewsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid := c.Param("userId")
|
||||
if uid == "" {
|
||||
uid = embyUserID(c)
|
||||
}
|
||||
uid := embyEffectiveUserID(c)
|
||||
out, err := svc.Emby.Views(c.Request.Context(), uid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
|
||||
@@ -722,7 +722,9 @@ func streamHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
if mount.ProxyPlay {
|
||||
// 远程 Emby 条目的同源转发:VR 全景需要浏览器读帧(见 stream_proxy.go),
|
||||
// 与挂载账号的 proxy_play 开关等价。
|
||||
if mount.ProxyPlay || wantSameOriginProxy(c) {
|
||||
if err := svc.Emby.ProxyRemoteVideoStream(ctx, c.Writer, c.Request, mountID, remoteID); err != nil {
|
||||
if !c.Writer.Written() {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
@@ -758,6 +760,15 @@ func streamHandler(svc *service.Container) gin.HandlerFunc {
|
||||
if !enforceScopedPlaybackToken(c, m.ID) {
|
||||
return
|
||||
}
|
||||
// ?proxy=1:把网盘/STRM 直链改为服务端同源转发(网页端读帧、VR 全景用),
|
||||
// 画质与原文件一致,不触发转码。
|
||||
if wantSameOriginProxy(c) {
|
||||
handled, proxyErr := proxySTRMStream(c, svc, m)
|
||||
if handled {
|
||||
writeProxyError(c, proxyErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
err = svc.Stream.ServeFile(c.Writer, c.Request, c.Param("id"))
|
||||
if errors.Is(err, service.ErrMediaNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/middleware"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
@@ -13,6 +14,45 @@ type pinnedLibrariesReq struct {
|
||||
LibraryIDs []string `json:"library_ids"`
|
||||
}
|
||||
|
||||
type libraryTagsReq struct {
|
||||
Tags []model.LibraryTagSet `json:"tags"`
|
||||
}
|
||||
|
||||
func getLibraryTagsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid, _ := c.Get(middleware.CtxUserID)
|
||||
tags, err := svc.Profile.GetLibraryTags(c.Request.Context(), uid.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if tags == nil {
|
||||
tags = []model.LibraryTagSet{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"tags": tags})
|
||||
}
|
||||
}
|
||||
|
||||
func setLibraryTagsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req libraryTagsReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
uid, _ := c.Get(middleware.CtxUserID)
|
||||
tags, err := svc.Profile.SetLibraryTags(c.Request.Context(), uid.(string), req.Tags)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if tags == nil {
|
||||
tags = []model.LibraryTagSet{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"tags": tags})
|
||||
}
|
||||
}
|
||||
|
||||
func getPinnedLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid, _ := c.Get(middleware.CtxUserID)
|
||||
|
||||
@@ -12,6 +12,8 @@ func registerAuthedUserAndLicenseRoutes(authed *gin.RouterGroup, svc *service.Co
|
||||
authed.PATCH("/me", updateProfileHandler(svc))
|
||||
authed.GET("/me/pinned-libraries", getPinnedLibrariesHandler(svc))
|
||||
authed.PUT("/me/pinned-libraries", setPinnedLibrariesHandler(svc))
|
||||
authed.GET("/me/library-tags", getLibraryTagsHandler(svc))
|
||||
authed.PUT("/me/library-tags", setLibraryTagsHandler(svc))
|
||||
authed.POST("/me/password", changePasswordHandler(svc))
|
||||
authed.POST("/me/logout", logoutHandler(svc))
|
||||
authed.GET("/me/temporary-password", temporaryPasswordHandler(svc))
|
||||
|
||||
@@ -27,6 +27,8 @@ func TestAuthenticatedRouteSurfacesAreRegistered(t *testing.T) {
|
||||
"GET /api/me",
|
||||
"GET /api/me/pinned-libraries",
|
||||
"PUT /api/me/pinned-libraries",
|
||||
"GET /api/me/library-tags",
|
||||
"PUT /api/me/library-tags",
|
||||
"GET /api/auth/permissions",
|
||||
"GET /api/libraries",
|
||||
"GET /api/media",
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// 直连流的同源转发:网页端需要读取视频帧的场景(VR 全景渲染走 WebGL 纹理)
|
||||
// 不能使用会跳到网盘 CDN 的跨域直链,这里把直链改为服务端转发。
|
||||
//
|
||||
// 只在客户端显式带上 ?proxy=1 时生效,普通播放仍走原来的 302 直连,
|
||||
// 避免把网盘流量无谓地压到服务器上。
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
// wantSameOriginProxy 客户端是否要求把直连流改为服务端同源转发。
|
||||
func wantSameOriginProxy(c *gin.Context) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(c.Query("proxy"))) {
|
||||
case "1", "true", "yes":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// proxySTRMStream 处理 STRM/网盘媒体的同源转发请求。返回 handled=false 表示
|
||||
// 该媒体不需要(或无法)代理,调用方继续按原有逻辑处理。
|
||||
func proxySTRMStream(c *gin.Context, svc *service.Container, m *model.Media) (bool, error) {
|
||||
if c == nil || svc == nil || svc.Strm == nil || m == nil {
|
||||
return false, nil
|
||||
}
|
||||
if !service.IsStrmMediaRow(m) {
|
||||
return false, nil
|
||||
}
|
||||
if err := svc.Strm.ProxyMediaDirect(c.Request.Context(), c.Writer, c.Request, m); err != nil {
|
||||
if errors.Is(err, service.ErrStrmProxyNotApplicable) {
|
||||
return false, nil
|
||||
}
|
||||
return true, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// writeProxyError 在尚未写入任何响应内容时回一个明确的网关错误。
|
||||
func writeProxyError(c *gin.Context, err error) {
|
||||
if c == nil || err == nil || c.Writer.Written() {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
}
|
||||
@@ -92,13 +92,14 @@ func historyStatsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
func historyContinueHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid, _ := c.Get(middleware.CtxUserID)
|
||||
userID := toString(uid)
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
|
||||
if limit <= 0 || limit > 50 {
|
||||
limit = 10
|
||||
}
|
||||
var rows []model.PlaybackHistory
|
||||
if err := svc.Repo.DB.
|
||||
Where("user_id = ? AND completed = ?", toString(uid), false).
|
||||
Where("user_id = ? AND completed = ?", userID, false).
|
||||
Order("watched_at desc").
|
||||
Limit(limit).
|
||||
Find(&rows).Error; err != nil {
|
||||
@@ -116,61 +117,52 @@ func historyContinueHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
mIdx := make(map[string]model.Media, len(media))
|
||||
for _, m := range media {
|
||||
if !mediaVisibleForRequest(c, svc, &m) {
|
||||
continue
|
||||
}
|
||||
mIdx[m.ID] = m
|
||||
}
|
||||
out := make([]gin.H, 0, len(rows))
|
||||
staleIDs := make([]string, 0)
|
||||
for _, r := range rows {
|
||||
m, ok := mIdx[r.MediaID]
|
||||
if !ok {
|
||||
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(r.MediaID) {
|
||||
mountID, remoteID, _ := service.DecodeEmbyRemoteID(r.MediaID)
|
||||
if mount, acct, _ := svc.EmbyRemote.ResolveMount(c.Request.Context(), mountID); mount != nil && acct != nil {
|
||||
if rm, err := svc.EmbyRemote.RemoteMediaDetail(c.Request.Context(), mount, acct, remoteID); err == nil && rm != nil {
|
||||
if mediaVisibleForRequest(c, svc, rm) {
|
||||
out = append(out, gin.H{
|
||||
"history": r,
|
||||
"media": *rm,
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
fallback := fallbackHistoryMedia(r.MediaID)
|
||||
if fallback != nil {
|
||||
if ok {
|
||||
if mediaVisibleForRequest(c, svc, &m) {
|
||||
out = append(out, gin.H{
|
||||
"history": r,
|
||||
"media": *fallback,
|
||||
"media": m,
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
out = append(out, gin.H{
|
||||
"history": r,
|
||||
"media": m,
|
||||
})
|
||||
|
||||
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(r.MediaID) {
|
||||
mountID, remoteID, _ := service.DecodeEmbyRemoteID(r.MediaID)
|
||||
mount, acct, resolveErr := svc.EmbyRemote.ResolveMount(c.Request.Context(), mountID)
|
||||
if resolveErr == nil && mount != nil && acct != nil {
|
||||
remoteMedia, detailErr := svc.EmbyRemote.RemoteMediaDetail(c.Request.Context(), mount, acct, remoteID)
|
||||
if detailErr == nil && remoteMedia != nil {
|
||||
if mediaVisibleForRequest(c, svc, remoteMedia) {
|
||||
out = append(out, gin.H{
|
||||
"history": r,
|
||||
"media": *remoteMedia,
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 媒体记录已不存在。继续返回占位卡只会让用户点击后遇到 404,
|
||||
// 因此清理这条失效播放记录,不再占用继续观看列表。
|
||||
staleIDs = append(staleIDs, r.MediaID)
|
||||
}
|
||||
if len(staleIDs) > 0 {
|
||||
_ = svc.Repo.DB.WithContext(c.Request.Context()).Unscoped().
|
||||
Where("user_id = ? AND media_id IN ?", userID, staleIDs).
|
||||
Delete(&model.PlaybackHistory{}).Error
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
func fallbackHistoryMedia(mediaID string) *model.Media {
|
||||
if mediaID == "" {
|
||||
return nil
|
||||
}
|
||||
title := "媒体"
|
||||
if service.IsEmbyRemoteID(mediaID) {
|
||||
title = "远程媒体"
|
||||
}
|
||||
return &model.Media{
|
||||
Base: model.Base{ID: mediaID},
|
||||
Title: title,
|
||||
}
|
||||
}
|
||||
|
||||
// historyDeleteHandler removes one or all history rows for the caller.
|
||||
//
|
||||
// DELETE /api/watch-history?media_id=xxx → delete just that media's row
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/middleware"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
func TestHistoryContinueRemovesMissingMedia(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.PlaybackHistory{}, &model.Media{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
valid := model.Media{
|
||||
Base: model.Base{ID: "media-valid"},
|
||||
Title: "有效影片",
|
||||
Path: "/media/valid.mkv",
|
||||
}
|
||||
if err := db.Create(&valid).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if err := db.Create(&model.PlaybackHistory{
|
||||
Base: model.Base{ID: "history-valid"},
|
||||
UserID: "user-1",
|
||||
MediaID: valid.ID,
|
||||
PositionMs: 30_000,
|
||||
DurationMs: 120_000,
|
||||
WatchedAt: now.Add(-time.Minute),
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&model.PlaybackHistory{
|
||||
Base: model.Base{ID: "history-stale"},
|
||||
UserID: "user-1",
|
||||
MediaID: "media-deleted",
|
||||
PositionMs: 60_000,
|
||||
DurationMs: 120_000,
|
||||
WatchedAt: now,
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
repos := repository.New(db)
|
||||
svc := &service.Container{
|
||||
Log: zap.NewNop(),
|
||||
Repo: repos,
|
||||
Playback: service.NewPlaybackService(zap.NewNop(), repos),
|
||||
}
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set(middleware.CtxUserID, "user-1")
|
||||
c.Next()
|
||||
})
|
||||
router.GET("/watch-history/continue", historyContinueHandler(svc))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/watch-history/continue?limit=10", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var body []struct {
|
||||
History model.PlaybackHistory `json:"history"`
|
||||
Media model.Media `json:"media"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(body) != 1 {
|
||||
t.Fatalf("continue watching rows = %d, want 1: %s", len(body), rec.Body.String())
|
||||
}
|
||||
if body[0].Media.ID != valid.ID || body[0].History.ID != "history-valid" {
|
||||
t.Fatalf("unexpected row: %#v", body[0])
|
||||
}
|
||||
|
||||
var staleCount int64
|
||||
if err := db.Unscoped().Model(&model.PlaybackHistory{}).
|
||||
Where("user_id = ? AND media_id = ?", "user-1", "media-deleted").
|
||||
Count(&staleCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if staleCount != 0 {
|
||||
t.Fatalf("stale history rows remaining = %d, want 0", staleCount)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MaxLibraryTags 是单个用户可创建的标签数量上限,避免恶意写入过大的 JSON。
|
||||
const MaxLibraryTags = 50
|
||||
|
||||
// MaxLibraryTagNameLen 是单个标签名的最大字符长度(按 rune 计数)。
|
||||
const MaxLibraryTagNameLen = 24
|
||||
|
||||
// LibraryTagSet 是用户自定义的媒体库标签分组。
|
||||
// LibraryIDs 保存该标签下媒体库的 ID(含远程 Emby 挂载库的 embyremote~ 形式),
|
||||
// 顺序即媒体库在该标签内的展示顺序。
|
||||
type LibraryTagSet struct {
|
||||
Name string `json:"name"`
|
||||
LibraryIDs []string `json:"library_ids"`
|
||||
}
|
||||
|
||||
// DecodeLibraryTags 解析 LibraryTags 字段,忽略损坏的数据。
|
||||
func (u *User) DecodeLibraryTags() []LibraryTagSet {
|
||||
if u == nil || strings.TrimSpace(u.LibraryTags) == "" {
|
||||
return nil
|
||||
}
|
||||
var tags []LibraryTagSet
|
||||
if err := json.Unmarshal([]byte(u.LibraryTags), &tags); err != nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]LibraryTagSet, 0, len(tags))
|
||||
for _, tag := range NormalizeLibraryTags(tags) {
|
||||
out = append(out, tag)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// EncodeLibraryTags 把标签集合序列化为可写入 LibraryTags 字段的 JSON 字符串。
|
||||
// 空集合序列化为空字符串,便于用零值表达"没有标签"。
|
||||
func EncodeLibraryTags(tags []LibraryTagSet) (string, error) {
|
||||
if len(tags) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
raw, err := json.Marshal(tags)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(raw), nil
|
||||
}
|
||||
|
||||
// NormalizeLibraryTags 清洗标签集合:去空白、丢弃空名标签、去掉标签名与
|
||||
// 标签内媒体库 ID 的重复项,并把标签名重复的项合并。保持传入顺序。
|
||||
func NormalizeLibraryTags(tags []LibraryTagSet) []LibraryTagSet {
|
||||
if len(tags) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]LibraryTagSet, 0, len(tags))
|
||||
indexByName := make(map[string]int, len(tags))
|
||||
for _, tag := range tags {
|
||||
name := TruncateLibraryTagName(tag.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if len(out) >= MaxLibraryTags {
|
||||
break
|
||||
}
|
||||
key := strings.ToLower(name)
|
||||
pos, exists := indexByName[key]
|
||||
if !exists {
|
||||
if len(out) >= MaxLibraryTags {
|
||||
break
|
||||
}
|
||||
out = append(out, LibraryTagSet{Name: name, LibraryIDs: []string{}})
|
||||
pos = len(out) - 1
|
||||
indexByName[key] = pos
|
||||
}
|
||||
seen := make(map[string]struct{}, len(out[pos].LibraryIDs))
|
||||
for _, id := range out[pos].LibraryIDs {
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
for _, id := range tag.LibraryIDs {
|
||||
trimmed := strings.TrimSpace(id)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[trimmed]; ok {
|
||||
continue
|
||||
}
|
||||
seen[trimmed] = struct{}{}
|
||||
out[pos].LibraryIDs = append(out[pos].LibraryIDs, trimmed)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
for i := range out {
|
||||
if out[i].LibraryIDs == nil {
|
||||
out[i].LibraryIDs = []string{}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TruncateLibraryTagName 去掉首尾空白并按 rune 截断到长度上限。
|
||||
func TruncateLibraryTagName(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(name)
|
||||
if len(runes) > MaxLibraryTagNameLen {
|
||||
runes = runes[:MaxLibraryTagNameLen]
|
||||
}
|
||||
return strings.TrimSpace(string(runes))
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package model
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeLibraryTagsMergesDuplicatesAndTrims(t *testing.T) {
|
||||
tags := []LibraryTagSet{
|
||||
{Name: " 动画 ", LibraryIDs: []string{" a ", "", "b", "a"}},
|
||||
{Name: "动画", LibraryIDs: []string{"c", "b"}},
|
||||
{Name: " ", LibraryIDs: []string{"x"}},
|
||||
{Name: "电影", LibraryIDs: nil},
|
||||
}
|
||||
got := NormalizeLibraryTags(tags)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("NormalizeLibraryTags len = %d, want 2 (%#v)", len(got), got)
|
||||
}
|
||||
if got[0].Name != "动画" {
|
||||
t.Fatalf("first tag name = %q, want 动画", got[0].Name)
|
||||
}
|
||||
want := []string{"a", "b", "c"}
|
||||
if len(got[0].LibraryIDs) != len(want) {
|
||||
t.Fatalf("first tag ids = %v, want %v", got[0].LibraryIDs, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[0].LibraryIDs[i] != want[i] {
|
||||
t.Fatalf("first tag ids = %v, want %v", got[0].LibraryIDs, want)
|
||||
}
|
||||
}
|
||||
if got[1].Name != "电影" || got[1].LibraryIDs == nil {
|
||||
t.Fatalf("empty tag should survive with an empty non-nil id list: %#v", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeLibraryTagsCapsCountAndNameLength(t *testing.T) {
|
||||
long := make([]rune, MaxLibraryTagNameLen+10)
|
||||
for i := range long {
|
||||
long[i] = 'x'
|
||||
}
|
||||
got := NormalizeLibraryTags([]LibraryTagSet{{Name: string(long)}})
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("len = %d, want 1", len(got))
|
||||
}
|
||||
if runes := []rune(got[0].Name); len(runes) != MaxLibraryTagNameLen {
|
||||
t.Fatalf("name length = %d, want %d", len(runes), MaxLibraryTagNameLen)
|
||||
}
|
||||
|
||||
many := make([]LibraryTagSet, 0, MaxLibraryTags+5)
|
||||
for i := 0; i < MaxLibraryTags+5; i++ {
|
||||
many = append(many, LibraryTagSet{Name: string(rune('a' + i%26)) + "-" + string(rune('a'+i/26))})
|
||||
}
|
||||
if capped := NormalizeLibraryTags(many); len(capped) > MaxLibraryTags {
|
||||
t.Fatalf("capped len = %d, want <= %d", len(capped), MaxLibraryTags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeDecodeLibraryTagsRoundTrip(t *testing.T) {
|
||||
user := &User{}
|
||||
if encoded, err := EncodeLibraryTags(nil); err != nil || encoded != "" {
|
||||
t.Fatalf("EncodeLibraryTags(nil) = %q, %v; want \"\", nil", encoded, err)
|
||||
}
|
||||
raw, err := EncodeLibraryTags([]LibraryTagSet{{Name: "动画", LibraryIDs: []string{"lib-1"}}})
|
||||
if err != nil {
|
||||
t.Fatalf("EncodeLibraryTags: %v", err)
|
||||
}
|
||||
user.LibraryTags = raw
|
||||
decoded := user.DecodeLibraryTags()
|
||||
if len(decoded) != 1 || decoded[0].Name != "动画" || len(decoded[0].LibraryIDs) != 1 || decoded[0].LibraryIDs[0] != "lib-1" {
|
||||
t.Fatalf("DecodeLibraryTags = %#v", decoded)
|
||||
}
|
||||
|
||||
user.LibraryTags = "{not json"
|
||||
if decoded := user.DecodeLibraryTags(); decoded != nil {
|
||||
t.Fatalf("corrupt payload should decode to nil, got %#v", decoded)
|
||||
}
|
||||
|
||||
user.PopulateComputedFields()
|
||||
if user.LibraryTagList != nil {
|
||||
t.Fatalf("PopulateComputedFields should mirror DecodeLibraryTags, got %#v", user.LibraryTagList)
|
||||
}
|
||||
}
|
||||
+18
-8
@@ -28,18 +28,27 @@ type User struct {
|
||||
// PinnedLibraryIDs 存储用户置顶的媒体库 ID 列表(JSON 字符串),顺序即置顶优先级。
|
||||
PinnedLibraryIDs string `gorm:"type:text" json:"-"`
|
||||
PinnedLibraryList []string `gorm:"-" json:"pinned_library_ids,omitempty"`
|
||||
// LibraryTags 存储用户自定义的媒体库标签分组(JSON 字符串),
|
||||
// 形如 [{"name":"动画","library_ids":["lib-1","lib-2"]}]。标签属于用户本人,
|
||||
// 用于在媒体库页把同一标签下的媒体库聚合到一起。
|
||||
LibraryTags string `gorm:"type:text" json:"-"`
|
||||
LibraryTagList []LibraryTagSet `gorm:"-" json:"library_tags,omitempty"`
|
||||
// SubtitleChineseMode 是网页播放器外挂字幕的简繁转换偏好:
|
||||
// original / simplified / traditional。
|
||||
SubtitleChineseMode string `gorm:"size:16;not null;default:original" json:"subtitle_chinese_mode"`
|
||||
// 网页播放器偏好按用户存储,切换媒体对象后继续沿用。
|
||||
PlayerVolume float64 `gorm:"not null;default:1" json:"player_volume"`
|
||||
DanmakuEnabled bool `gorm:"not null;default:true" json:"danmaku_enabled"`
|
||||
DanmakuOpacity float64 `gorm:"not null;default:1" json:"danmaku_opacity"`
|
||||
DanmakuFontSize int `gorm:"not null;default:24" json:"danmaku_font_size"`
|
||||
DanmakuArea float64 `gorm:"not null;default:1" json:"danmaku_area"`
|
||||
DanmakuMergeSources bool `gorm:"not null;default:false" json:"danmaku_merge_sources"`
|
||||
DanmakuSource string `gorm:"size:512" json:"danmaku_source,omitempty"`
|
||||
DanmakuAppID string `gorm:"size:128" json:"danmaku_app_id,omitempty"`
|
||||
PlayerVolume float64 `gorm:"not null;default:1" json:"player_volume"`
|
||||
PlayerPlaybackRate float64 `gorm:"not null;default:1" json:"player_playback_rate"`
|
||||
// PlayerVr360GuideSeen 记录用户是否已经看过 VR 全景播放的首次操作说明,
|
||||
// 按用户保存:看过一次之后不再弹出。
|
||||
PlayerVr360GuideSeen bool `gorm:"not null;default:false" json:"player_vr360_guide_seen"`
|
||||
DanmakuEnabled bool `gorm:"not null;default:true" json:"danmaku_enabled"`
|
||||
DanmakuOpacity float64 `gorm:"not null;default:1" json:"danmaku_opacity"`
|
||||
DanmakuFontSize int `gorm:"not null;default:24" json:"danmaku_font_size"`
|
||||
DanmakuArea float64 `gorm:"not null;default:1" json:"danmaku_area"`
|
||||
DanmakuMergeSources bool `gorm:"not null;default:false" json:"danmaku_merge_sources"`
|
||||
DanmakuSource string `gorm:"size:512" json:"danmaku_source,omitempty"`
|
||||
DanmakuAppID string `gorm:"size:128" json:"danmaku_app_id,omitempty"`
|
||||
// DanmakuAppKey 只在服务端读取并用于请求签名,绝不通过用户资料接口下发。
|
||||
DanmakuAppKey string `gorm:"size:256" json:"-"`
|
||||
// ExpiredAt is the account expiry time. Nil means the account never
|
||||
@@ -102,4 +111,5 @@ func (u *User) PopulateComputedFields() {
|
||||
}
|
||||
u.AllowedLibraryList = u.DecodeAllowedLibraryIDs()
|
||||
u.PinnedLibraryList = u.DecodePinnedLibraryIDs()
|
||||
u.LibraryTagList = u.DecodeLibraryTags()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -49,6 +49,12 @@ const (
|
||||
// dandanplay protocol (search/episodes + comment/{episodeId}) may be used.
|
||||
const DanmakuDefaultSource = "https://api.dandanplay.net"
|
||||
|
||||
const (
|
||||
minPlayerPlaybackRate = 0.5
|
||||
maxPlayerPlaybackRate = 3.0
|
||||
defaultPlayerPlaybackRate = 1.0
|
||||
)
|
||||
|
||||
// danmakuOfficialBase is where identification (/api/v2/match) and the
|
||||
// comment/search fallback always go, regardless of the configured source.
|
||||
// A package var (not a const) so tests can point it at a local server.
|
||||
@@ -63,6 +69,10 @@ type DanmakuRenderConfig struct {
|
||||
FontSize string `json:"font_size"`
|
||||
Area string `json:"area"`
|
||||
Volume float64 `json:"volume"`
|
||||
// PlaybackRate 是当前用户的播放倍速偏好(按用户存储)。
|
||||
PlaybackRate float64 `json:"playback_rate"`
|
||||
// Vr360GuideSeen 表示当前用户是否已经看过 VR 全景播放的首次操作说明。
|
||||
Vr360GuideSeen bool `json:"vr360_guide_seen"`
|
||||
// MergeSources 是当前用户的弹幕合并偏好(按用户存储)。
|
||||
MergeSources bool `json:"merge_sources"`
|
||||
// AppKeyConfigured 只表明用户是否保存了应用密钥,不回传密钥明文。
|
||||
@@ -89,6 +99,9 @@ type DanmakuSettingsPatch struct {
|
||||
Area *float64 `json:"area"`
|
||||
MergeSources *bool `json:"merge_sources"`
|
||||
Volume *float64 `json:"volume"`
|
||||
PlaybackRate *float64 `json:"playback_rate"`
|
||||
// Vr360GuideSeen 标记 VR 全景播放的首次操作说明是否已经看过。
|
||||
Vr360GuideSeen *bool `json:"vr360_guide_seen"`
|
||||
}
|
||||
|
||||
type danmakuUserContextKey struct{}
|
||||
@@ -345,10 +358,11 @@ func cloneDanmakuAnimeList(in []DanmakuAnime) []DanmakuAnime {
|
||||
// Config reads danmaku settings from the runtime settings table.
|
||||
func (s *DanmakuService) Config(ctx context.Context) DanmakuRenderConfig {
|
||||
cfg := DanmakuRenderConfig{
|
||||
Opacity: "1",
|
||||
FontSize: "24",
|
||||
Area: "1",
|
||||
Volume: 1,
|
||||
Opacity: "1",
|
||||
FontSize: "24",
|
||||
Area: "1",
|
||||
Volume: 1,
|
||||
PlaybackRate: defaultPlayerPlaybackRate,
|
||||
}
|
||||
if s == nil || s.repo == nil || s.repo.Setting == nil {
|
||||
return cfg
|
||||
@@ -399,7 +413,14 @@ func (s *DanmakuService) findUser(ctx context.Context, userID string) (*model.Us
|
||||
|
||||
func danmakuConfigFromUser(user *model.User) DanmakuRenderConfig {
|
||||
if user == nil {
|
||||
return DanmakuRenderConfig{Enabled: true, Opacity: "1", FontSize: "24", Area: "1", Volume: 1}
|
||||
return DanmakuRenderConfig{
|
||||
Enabled: true,
|
||||
Opacity: "1",
|
||||
FontSize: "24",
|
||||
Area: "1",
|
||||
Volume: 1,
|
||||
PlaybackRate: defaultPlayerPlaybackRate,
|
||||
}
|
||||
}
|
||||
opacity := user.DanmakuOpacity
|
||||
if opacity < 0.1 || opacity > 1 {
|
||||
@@ -417,6 +438,10 @@ func danmakuConfigFromUser(user *model.User) DanmakuRenderConfig {
|
||||
if volume < 0 || volume > 1 {
|
||||
volume = 1
|
||||
}
|
||||
playbackRate := user.PlayerPlaybackRate
|
||||
if playbackRate < minPlayerPlaybackRate || playbackRate > maxPlayerPlaybackRate {
|
||||
playbackRate = defaultPlayerPlaybackRate
|
||||
}
|
||||
return DanmakuRenderConfig{
|
||||
Enabled: user.DanmakuEnabled,
|
||||
Source: strings.TrimSpace(user.DanmakuSource),
|
||||
@@ -425,6 +450,8 @@ func danmakuConfigFromUser(user *model.User) DanmakuRenderConfig {
|
||||
FontSize: strconv.Itoa(fontSize),
|
||||
Area: strconv.FormatFloat(area, 'f', -1, 64),
|
||||
Volume: volume,
|
||||
PlaybackRate: playbackRate,
|
||||
Vr360GuideSeen: user.PlayerVr360GuideSeen,
|
||||
MergeSources: user.DanmakuMergeSources,
|
||||
AppKeyConfigured: strings.TrimSpace(user.DanmakuAppKey) != "",
|
||||
}
|
||||
@@ -495,6 +522,20 @@ func (s *DanmakuService) UpdateUserSettings(ctx context.Context, userID string,
|
||||
}
|
||||
updates["player_volume"] = *patch.Volume
|
||||
}
|
||||
if patch.PlaybackRate != nil {
|
||||
if *patch.PlaybackRate < minPlayerPlaybackRate || *patch.PlaybackRate > maxPlayerPlaybackRate {
|
||||
return DanmakuRenderConfig{}, fmt.Errorf(
|
||||
"%w: playback_rate must be between %.2f and %.2f",
|
||||
ErrInvalidDanmakuSettings,
|
||||
minPlayerPlaybackRate,
|
||||
maxPlayerPlaybackRate,
|
||||
)
|
||||
}
|
||||
updates["player_playback_rate"] = *patch.PlaybackRate
|
||||
}
|
||||
if patch.Vr360GuideSeen != nil {
|
||||
updates["player_vr360_guide_seen"] = *patch.Vr360GuideSeen
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
return DanmakuRenderConfig{}, ErrNoDanmakuSettings
|
||||
}
|
||||
|
||||
@@ -422,6 +422,7 @@ func TestDanmakuFetchUsesPerUserSourceAndCredentials(t *testing.T) {
|
||||
user.DanmakuFontSize = 30
|
||||
user.DanmakuArea = 0.7
|
||||
user.PlayerVolume = 0.42
|
||||
user.PlayerPlaybackRate = 1.25
|
||||
require.NoError(t, svc.repo.User.Create(ctx, &user))
|
||||
seedDanmakuMedia(t, svc, "per-user-media", "测试动画", "", 0)
|
||||
|
||||
@@ -430,6 +431,7 @@ func TestDanmakuFetchUsesPerUserSourceAndCredentials(t *testing.T) {
|
||||
require.Contains(t, res.Raw, "弹幕A")
|
||||
require.Equal(t, srv.URL(), res.Source)
|
||||
require.Equal(t, 0.42, res.Volume)
|
||||
require.Equal(t, 1.25, res.PlaybackRate)
|
||||
require.Equal(t, "0.65", res.Opacity)
|
||||
require.Equal(t, "30", res.FontSize)
|
||||
require.Equal(t, srv.lastHeaders.Get("X-AppId"), "user-app-id")
|
||||
|
||||
@@ -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 (
|
||||
@@ -205,6 +209,10 @@ func (e *EmbyService) Items(ctx context.Context, p ItemsParams) (map[string]any,
|
||||
if containsEmbyFilter(p.Filters, "IsFavorite") {
|
||||
return e.favoriteItems(ctx, p)
|
||||
}
|
||||
// 继续观看必须走 MeBox 本地 PlaybackHistory,不能转发到远程共用账号。
|
||||
if containsEmbyFilter(p.Filters, "IsResumable") {
|
||||
return e.resumableItems(ctx, p)
|
||||
}
|
||||
mountID, _, _ := DecodeEmbyRemoteID(p.ParentID)
|
||||
mount, acct, _ := e.remote.ResolveMount(ctx, mountID)
|
||||
if mount == nil || acct == nil {
|
||||
|
||||
@@ -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)}, "|")))
|
||||
|
||||
@@ -38,14 +38,6 @@ func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[str
|
||||
if err := e.mergeRemoteUserData(ctx, userID, out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if favorite, _ := IsUserFavorite(ctx, e.repo, userID, mediaID); favorite {
|
||||
userData, _ := out["UserData"].(map[string]any)
|
||||
if userData == nil {
|
||||
userData = map[string]any{}
|
||||
out["UserData"] = userData
|
||||
}
|
||||
userData["IsFavorite"] = true
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
if lib, err := e.repo.Library.FindByID(ctx, mediaID); err != nil {
|
||||
@@ -466,12 +458,26 @@ func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[st
|
||||
return map[string]any{"Items": []any{}, "TotalRecordCount": int64(0), "StartIndex": p.StartIndex}, nil
|
||||
}
|
||||
|
||||
mediaIDs := make([]string, 0, len(hist))
|
||||
localIDs := make([]string, 0, len(hist))
|
||||
for _, h := range hist {
|
||||
mediaIDs = append(mediaIDs, h.MediaID)
|
||||
if !IsEmbyRemoteID(h.MediaID) {
|
||||
localIDs = append(localIDs, h.MediaID)
|
||||
}
|
||||
}
|
||||
favSet := map[string]bool{}
|
||||
if len(mediaIDs) > 0 {
|
||||
var favs []model.Favorite
|
||||
if err := e.repo.DB.WithContext(ctx).
|
||||
Where("user_id = ? AND media_id IN ?", p.UserID, mediaIDs).
|
||||
Find(&favs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, fav := range favs {
|
||||
favSet[fav.MediaID] = true
|
||||
}
|
||||
}
|
||||
byID := map[string]*model.Media{}
|
||||
if len(localIDs) > 0 {
|
||||
var medias []model.Media
|
||||
@@ -498,7 +504,7 @@ func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[st
|
||||
continue
|
||||
}
|
||||
localTotal++
|
||||
slots = append(slots, resumeSlot{item: e.itemPayload(ctx, m, false, h.PositionMs, false)})
|
||||
slots = append(slots, resumeSlot{item: e.itemPayload(ctx, m, favSet[h.MediaID], h.PositionMs, false)})
|
||||
continue
|
||||
}
|
||||
if e.remote == nil || !IsEmbyRemoteID(h.MediaID) {
|
||||
@@ -559,7 +565,7 @@ func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[st
|
||||
continue
|
||||
}
|
||||
}
|
||||
item["UserData"] = mergedRemoteUserData(item["UserData"], &f.hist)
|
||||
item["UserData"] = applyMeBoxUserData(item["UserData"], &f.hist, favSet[f.hist.MediaID])
|
||||
slots[i].item = item
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -880,6 +880,9 @@ func (r *EmbyRemoteService) RemoteLatestForDisplay(ctx context.Context, mount *m
|
||||
// RemoteLatestSeries 拉取剧集库最近更新的 Series。部分 Emby 服务不支持
|
||||
// DateLastContentAdded 或过滤 Series,此时回退到 Latest 并把 Episode 归并到
|
||||
// 对应 Series。
|
||||
//
|
||||
// 使用 Recursive=true 并跳过 anime/ 等中间容器,与 RemoteSeriesCards /
|
||||
// Emby 客户端列剧集方式一致。
|
||||
func (r *EmbyRemoteService) RemoteLatestSeries(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteViewID string, limit int) ([]map[string]any, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20
|
||||
@@ -891,17 +894,32 @@ func (r *EmbyRemoteService) RemoteLatestSeries(ctx context.Context, mount *model
|
||||
q := url.Values{}
|
||||
q.Set("ParentId", remoteViewID)
|
||||
q.Set("IncludeItemTypes", "Series")
|
||||
q.Set("Recursive", "false")
|
||||
q.Set("Recursive", "true")
|
||||
q.Set("SortBy", "DateLastContentAdded")
|
||||
q.Set("SortOrder", "Descending")
|
||||
q.Set("Limit", strconv.Itoa(limit))
|
||||
// 多取一些以便滤掉中间容器后仍够 limit。
|
||||
q.Set("Limit", strconv.Itoa(limit*4))
|
||||
q.Set("Fields", "Overview,Genres,ProviderIds,Path,RecursiveItemCount,SeriesPrimaryImage,DateCreated,DateLastMediaAdded,PremiereDate,ProductionYear,CommunityRating,CriticRating")
|
||||
var body struct {
|
||||
Items []map[string]any `json:"Items"`
|
||||
}
|
||||
if err := r.doGet(ctx, acct, cfg, "/Users/"+url.PathEscape(r.remoteUserID(cfg))+"/Items", q, &body); err == nil && len(body.Items) > 0 {
|
||||
RewriteEmbyRemoteIDs(body.Items, mount.ID)
|
||||
return body.Items, nil
|
||||
filtered := make([]map[string]any, 0, limit)
|
||||
for _, it := range body.Items {
|
||||
name := remoteItemString(it, "Name")
|
||||
path := remoteItemString(it, "Path")
|
||||
if remoteSeriesItemLooksLikeContainer(name, path) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, it)
|
||||
if len(filtered) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(filtered) > 0 {
|
||||
RewriteEmbyRemoteIDs(filtered, mount.ID)
|
||||
return filtered, nil
|
||||
}
|
||||
}
|
||||
|
||||
items, err := r.RemoteLatest(ctx, mount, acct, remoteViewID, limit)
|
||||
|
||||
@@ -516,17 +516,17 @@ const remoteSeriesPageSize = 200
|
||||
|
||||
// RemoteSeriesCards 远程剧集库的系列卡片(ChildCount 作为集数)。
|
||||
//
|
||||
// 远程 Emby 的 Series DTO 不会返回 DateLastMediaAdded 字段(即使请求 Fields
|
||||
// 也缺失),但其服务端排序支持 SortBy=DateLastContentAdded——即客户端"上次
|
||||
// 添加集日期"排序。因此这里直接按该键倒序分页拉全量,返回的卡片顺序与对方
|
||||
// Emby 客户端选择"上次添加集日期"完全一致;LastAddedAt 在远程提供字段时
|
||||
// 才填充,否则保持 nil(前端对无该值的卡片维持服务器顺序,不再回退加入日期)。
|
||||
// 与 Emby 客户端一致:IncludeItemTypes=Series + Recursive=true,按
|
||||
// DateLastContentAdded 倒序分页拉全库剧集。多媒体根下的 anime/ 等中间容器
|
||||
// 若偶发出现在结果里则过滤掉;LastAddedAt 仅在远程提供 DateLastMediaAdded
|
||||
// 时填充。
|
||||
func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteViewID string) ([]SeriesCard, error) {
|
||||
cfg, err := r.remoteConfigWithToken(ctx, acct)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cacheKey := r.remoteCacheKey("series-cards", acct.ID, mount.ID, remoteViewID)
|
||||
// v3:Recursive=true(对齐 Emby 客户端),与旧直属/下探缓存区分。
|
||||
cacheKey := r.remoteCacheKey("series-cards-v3", acct.ID, mount.ID, remoteViewID)
|
||||
var cached []SeriesCard
|
||||
if r.cache != nil && r.cache.GetJSON(ctx, cacheKey, &cached) {
|
||||
return cached, nil
|
||||
@@ -534,7 +534,7 @@ func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, mount *model.
|
||||
q := url.Values{}
|
||||
q.Set("ParentId", remoteViewID)
|
||||
q.Set("IncludeItemTypes", "Series")
|
||||
q.Set("Recursive", "false")
|
||||
q.Set("Recursive", "true")
|
||||
q.Set("SortBy", "DateLastContentAdded")
|
||||
q.Set("SortOrder", "Descending")
|
||||
q.Set("Limit", strconv.Itoa(remoteSeriesPageSize))
|
||||
@@ -554,9 +554,13 @@ func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, mount *model.
|
||||
break
|
||||
}
|
||||
for _, it := range body.Items {
|
||||
name := remoteItemString(it, "Name")
|
||||
path := remoteItemString(it, "Path")
|
||||
if remoteSeriesItemLooksLikeContainer(name, path) {
|
||||
continue
|
||||
}
|
||||
RewriteEmbyRemoteIDs(it, mount.ID)
|
||||
m := r.MapRemoteItemToMedia(ctx, mount, acct, cfg, it)
|
||||
// 集数优先用递归条目数(ChildCount 只算直属 Season 文件夹数)。
|
||||
count := remoteItemInt(it, "RecursiveItemCount")
|
||||
if count == 0 {
|
||||
count = remoteItemInt(it, "ChildCount")
|
||||
@@ -577,7 +581,7 @@ func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, mount *model.
|
||||
LastAddedAt: lastAdded,
|
||||
})
|
||||
}
|
||||
if int64(len(cards)) >= body.TotalRecordCount || len(body.Items) < remoteSeriesPageSize {
|
||||
if int64(startIndex+len(body.Items)) >= body.TotalRecordCount || len(body.Items) < remoteSeriesPageSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -587,6 +591,14 @@ func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, mount *model.
|
||||
return cards, nil
|
||||
}
|
||||
|
||||
func remoteSeriesItemLooksLikeContainer(name, path string) bool {
|
||||
if isEmbyGenericContainer(name) {
|
||||
return true
|
||||
}
|
||||
base := pathBaseSlash(strings.TrimRight(strings.ReplaceAll(path, "\\", "/"), "/"))
|
||||
return base != "" && isEmbyGenericContainer(base)
|
||||
}
|
||||
|
||||
// RemoteLatestCards 远程库最新条目(首页预览卡片),映射 SeriesCard。
|
||||
func (r *EmbyRemoteService) RemoteLatestCards(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteViewID string, limit int) ([]SeriesCard, error) {
|
||||
cfg, err := r.remoteConfigWithToken(ctx, acct)
|
||||
|
||||
@@ -121,10 +121,15 @@ func TestRemoteSeriesCardsAutoAuthOnFirstBrowse(t *testing.T) {
|
||||
}
|
||||
if r.URL.Path == "/emby/Users/real-user-guid/Items" {
|
||||
q := r.URL.Query()
|
||||
if q.Get("ParentId") != "view-1" || q.Get("IncludeItemTypes") != "Series" {
|
||||
if q.Get("IncludeItemTypes") != "Series" || q.Get("ParentId") != "view-1" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if q.Get("Recursive") != "true" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte("expected Recursive=true"))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"TotalRecordCount": 1,
|
||||
@@ -231,6 +236,11 @@ func TestRemoteSeriesCardsResolveUserIDFromAPIKey(t *testing.T) {
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/emby/Users/real-user-guid/Items" {
|
||||
q := r.URL.Query()
|
||||
if q.Get("ParentId") != "view-2" || q.Get("Recursive") != "true" {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"TotalRecordCount": 1,
|
||||
@@ -674,6 +684,11 @@ func TestRemoteSeriesCardsFetchAllPages(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
query := r.URL.Query()
|
||||
if query.Get("Recursive") != "true" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte("expected Recursive=true"))
|
||||
return
|
||||
}
|
||||
startIndex, _ := strconv.Atoi(query.Get("StartIndex"))
|
||||
limit, _ := strconv.Atoi(query.Get("Limit"))
|
||||
requests.Add(1)
|
||||
@@ -740,6 +755,84 @@ func TestRemoteSeriesCardsFetchAllPages(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 与 Emby 客户端一致:Recursive=true 拉全库 Series,并过滤 anime 等中间容器。
|
||||
func TestRemoteSeriesCardsRecursiveFiltersAnimeContainers(t *testing.T) {
|
||||
var sawRecursive atomic.Bool
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if strings.TrimPrefix(r.URL.Path, "/emby") != "/Users/remote-user/Items" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
if q.Get("ParentId") != "view-2023" || q.Get("IncludeItemTypes") != "Series" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if q.Get("Recursive") == "true" {
|
||||
sawRecursive.Store(true)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"TotalRecordCount": 5,
|
||||
"Items": []map[string]any{
|
||||
{"Id": "c0", "Name": "anime", "Type": "Series", "Path": "https://cdn/0/anime/", "RecursiveItemCount": 3086},
|
||||
{"Id": "c1", "Name": "anime", "Type": "Series", "Path": "https://cdn/1/anime/", "RecursiveItemCount": 567},
|
||||
{"Id": "s-a", "Name": "数码宝贝 BEATBREAK", "Type": "Series", "Path": "https://cdn/0/anime/digimon", "RecursiveItemCount": 24},
|
||||
{"Id": "s-b", "Name": "活死喵之夜", "Type": "Series", "Path": "https://cdn/0/anime/nyaight", "RecursiveItemCount": 12},
|
||||
{"Id": "s-c", "Name": "药屋少女的呢喃", "Type": "Series", "Path": "https://cdn/1/anime/kusuriya", "RecursiveItemCount": 51},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
|
||||
repos := repository.New(db)
|
||||
svc := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
|
||||
|
||||
rawConfig, _ := json.Marshal(map[string]string{
|
||||
"url": server.URL,
|
||||
"token": "fake-token",
|
||||
"remote_user_id": "remote-user",
|
||||
})
|
||||
acct := &model.StrmAccount{
|
||||
Base: model.Base{ID: "acct-recursive"},
|
||||
Name: "nijigem",
|
||||
Provider: model.StrmProviderEmbyRemote,
|
||||
Config: string(rawConfig),
|
||||
Enabled: true,
|
||||
}
|
||||
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
|
||||
t.Fatalf("create account: %v", err)
|
||||
}
|
||||
mount := &model.EmbyMount{
|
||||
Base: model.Base{ID: "mount-recursive"},
|
||||
AccountID: acct.ID,
|
||||
RemoteViewID: "view-2023",
|
||||
RemoteViewName: "2023前 动漫",
|
||||
CollectionType: "tvshows",
|
||||
Enabled: true,
|
||||
}
|
||||
if err := repos.EmbyMount.Create(t.Context(), mount); err != nil {
|
||||
t.Fatalf("create mount: %v", err)
|
||||
}
|
||||
|
||||
cards, err := svc.RemoteSeriesCards(t.Context(), mount, acct, mount.RemoteViewID)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoteSeriesCards failed: %v", err)
|
||||
}
|
||||
if !sawRecursive.Load() {
|
||||
t.Fatal("expected Recursive=true on remote Items request")
|
||||
}
|
||||
if len(cards) != 3 {
|
||||
t.Fatalf("cards = %d, want 3 real series after filtering anime containers", len(cards))
|
||||
}
|
||||
for _, card := range cards {
|
||||
if strings.EqualFold(card.Rep.Title, "anime") {
|
||||
t.Fatalf("container title %q should have been filtered", card.Rep.Title)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 首页「最新条目」卡片 key 必须与媒体库剧集列表的 key 一致,否则点击后
|
||||
// 媒体库页找不到目标剧集,只能退回整库列表。
|
||||
func TestRemoteLatestCardsKeyMatchesSeriesList(t *testing.T) {
|
||||
@@ -751,6 +844,14 @@ func TestRemoteLatestCardsKeyMatchesSeriesList(t *testing.T) {
|
||||
return
|
||||
}
|
||||
seriesQueryCalls.Add(1)
|
||||
parentID := r.URL.Query().Get("ParentId")
|
||||
if parentID == "series-latest" {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"Items": []map[string]any{},
|
||||
"TotalRecordCount": 0,
|
||||
})
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"Items": []map[string]any{
|
||||
{
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// SetFavorite 把 mediaID 标为 userID 的收藏。挂载的远程 Emby 条目会同时写入
|
||||
// 本地 favourites 表并透传到对应远程服务器,保证网页与第三方 Emby 客户端一致。
|
||||
// SetFavorite 把 mediaID 标为 userID 的收藏。只写入 MeBox 本地 favourites 表,
|
||||
// 按 user_id 隔离;挂载远程 Emby 共用账号,不能再透传收藏以免串用户。
|
||||
func (e *EmbyService) SetFavorite(ctx context.Context, userID, mediaID string, favorite bool) error {
|
||||
if err := SyncUserFavorite(ctx, e.repo, e.remote, userID, mediaID, favorite); err != nil {
|
||||
return err
|
||||
@@ -22,15 +22,8 @@ func (e *EmbyService) SetFavorite(ctx context.Context, userID, mediaID string, f
|
||||
}
|
||||
|
||||
// MarkPlayed 把 mediaID 标为已看(写一个 100% 进度的 history 行)。
|
||||
// 远程 Emby 条目直接透传到对应服务器(本地不落库)。
|
||||
// 远程挂载条目同样只落本地 PlaybackHistory,按 MeBox 用户隔离。
|
||||
func (e *EmbyService) MarkPlayed(ctx context.Context, userID, mediaID string, played bool) error {
|
||||
if e.remote != nil && IsEmbyRemoteID(mediaID) {
|
||||
acctID, remoteID, _ := DecodeEmbyRemoteID(mediaID)
|
||||
if err := e.ProxyRemoteSetPlayed(ctx, acctID, remoteID, played); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !played {
|
||||
err := e.repo.DB.WithContext(ctx).
|
||||
Where("user_id = ? AND media_id = ?", userID, mediaID).
|
||||
@@ -40,15 +33,20 @@ func (e *EmbyService) MarkPlayed(ctx context.Context, userID, mediaID string, pl
|
||||
}
|
||||
return err
|
||||
}
|
||||
m, err := e.repo.Media.FindByID(ctx, mediaID)
|
||||
if err != nil || m == nil {
|
||||
return errors.New("media not found")
|
||||
dur := int64(0)
|
||||
if IsEmbyRemoteID(mediaID) {
|
||||
dur = remoteItemDurationMs(ctx, e, mediaID)
|
||||
} else {
|
||||
m, err := e.repo.Media.FindByID(ctx, mediaID)
|
||||
if err != nil || m == nil {
|
||||
return errors.New("media not found")
|
||||
}
|
||||
dur = int64(m.DurationSec) * 1000
|
||||
}
|
||||
dur := int64(m.DurationSec) * 1000
|
||||
if dur <= 0 {
|
||||
dur = 1
|
||||
}
|
||||
err = e.repo.History.Upsert(ctx, &model.PlaybackHistory{
|
||||
err := e.repo.History.Upsert(ctx, &model.PlaybackHistory{
|
||||
UserID: userID,
|
||||
MediaID: mediaID,
|
||||
PositionMs: dur,
|
||||
@@ -62,6 +60,36 @@ func (e *EmbyService) MarkPlayed(ctx context.Context, userID, mediaID string, pl
|
||||
return err
|
||||
}
|
||||
|
||||
func remoteItemDurationMs(ctx context.Context, e *EmbyService, mediaID string) int64 {
|
||||
if e == nil || e.remote == nil || !IsEmbyRemoteID(mediaID) {
|
||||
return 0
|
||||
}
|
||||
mountID, remoteID, _ := DecodeEmbyRemoteID(mediaID)
|
||||
mount, acct, err := e.remote.ResolveMount(ctx, mountID)
|
||||
if err != nil || mount == nil || acct == nil {
|
||||
return 0
|
||||
}
|
||||
item, err := e.remote.RemoteItem(ctx, mount, acct, remoteID)
|
||||
if err != nil || item == nil {
|
||||
return 0
|
||||
}
|
||||
switch ticks := item["RunTimeTicks"].(type) {
|
||||
case float64:
|
||||
if ticks > 0 {
|
||||
return int64(ticks) / 10_000
|
||||
}
|
||||
case int64:
|
||||
if ticks > 0 {
|
||||
return ticks / 10_000
|
||||
}
|
||||
case int:
|
||||
if ticks > 0 {
|
||||
return int64(ticks) / 10_000
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// RecordProgress 记录播放进度(来自 Emby 客户端的 /Sessions/Playing/Progress)。
|
||||
// 不携带 PlaySessionId 的旧调用仍保持兼容。
|
||||
func (e *EmbyService) RecordProgress(ctx context.Context, userID, mediaID string, positionTicks, runtimeTicks int64) error {
|
||||
@@ -138,8 +166,9 @@ func embyPlaySessionStartedAtMs(playSessionID string) int64 {
|
||||
return value
|
||||
}
|
||||
|
||||
// mergeRemoteUserData applies the current MeBox user's locally recorded playback
|
||||
// and favourite state to remote Emby payloads.
|
||||
// mergeRemoteUserData overlays the current MeBox user's locally recorded
|
||||
// playback and favourite state onto remote Emby payloads. Remote mounts share
|
||||
// one upstream Emby account, so upstream UserData must never leak across MeBox users.
|
||||
func (e *EmbyService) mergeRemoteUserData(ctx context.Context, userID string, payload any) error {
|
||||
if strings.TrimSpace(userID) == "" || payload == nil {
|
||||
return nil
|
||||
@@ -178,18 +207,7 @@ func (e *EmbyService) mergeRemoteUserData(ctx context.Context, userID string, pa
|
||||
}
|
||||
for _, item := range items {
|
||||
id, _ := item["Id"].(string)
|
||||
userData, _ := item["UserData"].(map[string]any)
|
||||
if h := byMediaID[id]; h != nil {
|
||||
item["UserData"] = mergedRemoteUserData(userData, h)
|
||||
userData, _ = item["UserData"].(map[string]any)
|
||||
}
|
||||
if favSet[id] {
|
||||
if userData == nil {
|
||||
userData = map[string]any{}
|
||||
item["UserData"] = userData
|
||||
}
|
||||
userData["IsFavorite"] = true
|
||||
}
|
||||
item["UserData"] = applyMeBoxUserData(item["UserData"], byMediaID[id], favSet[id])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -221,12 +239,37 @@ func remoteItemMaps(payload any) []map[string]any {
|
||||
}
|
||||
|
||||
func mergedRemoteUserData(raw any, history *model.PlaybackHistory) map[string]any {
|
||||
favorite := false
|
||||
if existing, ok := raw.(map[string]any); ok {
|
||||
if v, ok := existing["IsFavorite"].(bool); ok {
|
||||
favorite = v
|
||||
}
|
||||
}
|
||||
return applyMeBoxUserData(raw, history, favorite)
|
||||
}
|
||||
|
||||
// applyMeBoxUserData rebuilds UserData for a remote item using only MeBox-local
|
||||
// per-user state. Shared upstream Emby favourite/progress fields are discarded.
|
||||
func applyMeBoxUserData(raw any, history *model.PlaybackHistory, favorite bool) map[string]any {
|
||||
userData := map[string]any{}
|
||||
if existing, ok := raw.(map[string]any); ok {
|
||||
for key, value := range existing {
|
||||
userData[key] = value
|
||||
switch key {
|
||||
case "IsFavorite", "PlaybackPositionTicks", "Played", "PlayedPercentage", "PlayCount", "LastPlayedDate":
|
||||
continue
|
||||
default:
|
||||
userData[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
userData["IsFavorite"] = favorite
|
||||
if history == nil {
|
||||
userData["PlaybackPositionTicks"] = int64(0)
|
||||
userData["Played"] = false
|
||||
userData["PlayedPercentage"] = float64(0)
|
||||
userData["PlayCount"] = 0
|
||||
return userData
|
||||
}
|
||||
duration := history.DurationMs
|
||||
position := history.PositionMs
|
||||
percentage := float64(0)
|
||||
@@ -237,18 +280,9 @@ func mergedRemoteUserData(raw any, history *model.PlaybackHistory) map[string]an
|
||||
userData["Played"] = history.Completed
|
||||
userData["PlayedPercentage"] = percentage
|
||||
if history.Completed {
|
||||
playCount := 0
|
||||
switch value := userData["PlayCount"].(type) {
|
||||
case int:
|
||||
playCount = value
|
||||
case int64:
|
||||
playCount = int(value)
|
||||
case float64:
|
||||
playCount = int(value)
|
||||
}
|
||||
if playCount < 1 {
|
||||
userData["PlayCount"] = 1
|
||||
}
|
||||
userData["PlayCount"] = 1
|
||||
} else {
|
||||
userData["PlayCount"] = 0
|
||||
}
|
||||
return userData
|
||||
}
|
||||
|
||||
@@ -18,16 +18,18 @@ func TestMergedRemoteUserData(t *testing.T) {
|
||||
preserve any
|
||||
}{
|
||||
{
|
||||
name: "in-progress preserves remote fields",
|
||||
name: "in-progress preserves non-user remote fields only",
|
||||
raw: map[string]any{
|
||||
"PlayCount": 2,
|
||||
"Custom": "remote-value",
|
||||
"PlayCount": 2,
|
||||
"IsFavorite": true,
|
||||
"PlaybackPositionTicks": int64(999),
|
||||
"Custom": "remote-value",
|
||||
},
|
||||
history: model.PlaybackHistory{PositionMs: 25_000, DurationMs: 100_000},
|
||||
position: 250_000_000,
|
||||
played: false,
|
||||
percent: 25,
|
||||
count: 2,
|
||||
count: 0,
|
||||
preserve: "remote-value",
|
||||
},
|
||||
{
|
||||
@@ -59,10 +61,47 @@ func TestMergedRemoteUserData(t *testing.T) {
|
||||
if tt.preserve != nil && out["Custom"] != tt.preserve {
|
||||
t.Fatalf("Custom = %#v, want %#v", out["Custom"], tt.preserve)
|
||||
}
|
||||
if out["IsFavorite"] != false && out["IsFavorite"] != true {
|
||||
t.Fatalf("IsFavorite missing: %#v", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMeBoxUserDataClearsSharedRemoteState(t *testing.T) {
|
||||
out := applyMeBoxUserData(map[string]any{
|
||||
"IsFavorite": true,
|
||||
"PlaybackPositionTicks": int64(42_000_000),
|
||||
"Played": true,
|
||||
"PlayedPercentage": 80.0,
|
||||
"PlayCount": 3,
|
||||
"Key": "keep",
|
||||
}, nil, false)
|
||||
if out["IsFavorite"] != false {
|
||||
t.Fatalf("IsFavorite = %#v, want false", out["IsFavorite"])
|
||||
}
|
||||
if out["PlaybackPositionTicks"] != int64(0) {
|
||||
t.Fatalf("PlaybackPositionTicks = %#v, want 0", out["PlaybackPositionTicks"])
|
||||
}
|
||||
if out["Played"] != false {
|
||||
t.Fatalf("Played = %#v, want false", out["Played"])
|
||||
}
|
||||
if out["PlayedPercentage"] != float64(0) {
|
||||
t.Fatalf("PlayedPercentage = %#v, want 0", out["PlayedPercentage"])
|
||||
}
|
||||
if out["PlayCount"] != 0 {
|
||||
t.Fatalf("PlayCount = %#v, want 0", out["PlayCount"])
|
||||
}
|
||||
if out["Key"] != "keep" {
|
||||
t.Fatalf("Key = %#v, want keep", out["Key"])
|
||||
}
|
||||
|
||||
fav := applyMeBoxUserData(map[string]any{"IsFavorite": false}, nil, true)
|
||||
if fav["IsFavorite"] != true {
|
||||
t.Fatalf("favorite overlay IsFavorite = %#v, want true", fav["IsFavorite"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteItemMapsFindsEnvelopeItems(t *testing.T) {
|
||||
remoteID := EncodeEmbyRemoteID("mount-1", "item-1")
|
||||
payload := map[string]any{
|
||||
@@ -108,3 +147,57 @@ func TestRecordProgressFallbacksToExistingHistoryDuration(t *testing.T) {
|
||||
t.Fatalf("expected 95%% progress to be completed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkPlayedStoresRemoteItemLocallyPerUser(t *testing.T) {
|
||||
svc := newTestEmbyService(t)
|
||||
remoteID := EncodeEmbyRemoteID("mount-test", "item-played")
|
||||
alice := &model.User{Username: "alice_played", Role: "user", Tier: "free", IsActive: true}
|
||||
bob := &model.User{Username: "bob_played", Role: "user", Tier: "free", IsActive: true}
|
||||
if err := svc.repo.User.Create(t.Context(), alice); err != nil {
|
||||
t.Fatalf("create alice: %v", err)
|
||||
}
|
||||
if err := svc.repo.User.Create(t.Context(), bob); err != nil {
|
||||
t.Fatalf("create bob: %v", err)
|
||||
}
|
||||
|
||||
if err := svc.MarkPlayed(t.Context(), alice.ID, remoteID, true); err != nil {
|
||||
t.Fatalf("mark played: %v", err)
|
||||
}
|
||||
|
||||
var aliceRows, bobRows int64
|
||||
_ = svc.repo.DB.Model(&model.PlaybackHistory{}).Where("user_id = ? AND media_id = ?", alice.ID, remoteID).Count(&aliceRows)
|
||||
_ = svc.repo.DB.Model(&model.PlaybackHistory{}).Where("user_id = ? AND media_id = ?", bob.ID, remoteID).Count(&bobRows)
|
||||
if aliceRows != 1 {
|
||||
t.Fatalf("alice history rows = %d, want 1", aliceRows)
|
||||
}
|
||||
if bobRows != 0 {
|
||||
t.Fatalf("bob should not see alice remote played state, rows=%d", bobRows)
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"Id": remoteID,
|
||||
"UserData": map[string]any{
|
||||
"IsFavorite": true,
|
||||
"PlaybackPositionTicks": int64(50_000_000),
|
||||
"Played": true,
|
||||
},
|
||||
}
|
||||
if err := svc.mergeRemoteUserData(t.Context(), bob.ID, payload); err != nil {
|
||||
t.Fatalf("merge for bob: %v", err)
|
||||
}
|
||||
bobData := payload["UserData"].(map[string]any)
|
||||
if bobData["IsFavorite"] != false {
|
||||
t.Fatalf("bob IsFavorite leaked: %#v", bobData)
|
||||
}
|
||||
if bobData["Played"] != false || bobData["PlaybackPositionTicks"] != int64(0) {
|
||||
t.Fatalf("bob playback leaked: %#v", bobData)
|
||||
}
|
||||
|
||||
if err := svc.mergeRemoteUserData(t.Context(), alice.ID, payload); err != nil {
|
||||
t.Fatalf("merge for alice: %v", err)
|
||||
}
|
||||
aliceData := payload["UserData"].(map[string]any)
|
||||
if aliceData["Played"] != true {
|
||||
t.Fatalf("alice Played = %#v, want true", aliceData["Played"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,21 +10,14 @@ import (
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
// SyncUserFavorite keeps favourite state aligned across the local favourites table
|
||||
// and the upstream remote Emby server for mounted items.
|
||||
func SyncUserFavorite(ctx context.Context, repo *repository.Container, remote *EmbyRemoteService, userID, mediaID string, favorite bool) error {
|
||||
// SyncUserFavorite keeps favourite state in the local favourites table, keyed
|
||||
// by MeBox user_id. Remote Emby mounts share one upstream account, so favourite
|
||||
// state is intentionally not proxied upstream.
|
||||
func SyncUserFavorite(ctx context.Context, repo *repository.Container, _ *EmbyRemoteService, userID, mediaID string, favorite bool) error {
|
||||
if repo == nil || userID == "" || mediaID == "" {
|
||||
return errors.New("missing favourite sync inputs")
|
||||
}
|
||||
if err := setLocalFavorite(ctx, repo, userID, mediaID, favorite); err != nil {
|
||||
return err
|
||||
}
|
||||
if favorite || IsEmbyRemoteID(mediaID) {
|
||||
if err := proxyRemoteFavorite(ctx, remote, mediaID, favorite); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return setLocalFavorite(ctx, repo, userID, mediaID, favorite)
|
||||
}
|
||||
|
||||
// IsUserFavorite reports whether the user has favourited mediaID locally.
|
||||
@@ -57,18 +50,3 @@ func setLocalFavorite(ctx context.Context, repo *repository.Container, userID, m
|
||||
Where("user_id = ? AND media_id = ?", userID, mediaID).
|
||||
Delete(&model.Favorite{}).Error
|
||||
}
|
||||
|
||||
func proxyRemoteFavorite(ctx context.Context, remote *EmbyRemoteService, mediaID string, favorite bool) error {
|
||||
if remote == nil || !IsEmbyRemoteID(mediaID) {
|
||||
return nil
|
||||
}
|
||||
mountID, remoteItemID, ok := DecodeEmbyRemoteID(mediaID)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
_, acct, err := remote.ResolveMount(ctx, mountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return remote.ProxySetFavorite(ctx, acct, remoteItemID, favorite)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -179,9 +178,21 @@ func (s *MediaService) listVersionSiblings(ctx context.Context, media *model.Med
|
||||
libraryIDs = []string{media.LibraryID}
|
||||
}
|
||||
filter := repository.MediaQueryFilter{IncludeNSFW: true}
|
||||
candidates, narrowed, err := s.repo.Media.ListVersionCandidates(ctx, libraryIDs, *media, 5000)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// 成人条目按番号分组,而番号常常只存在于路径/标题里:本地 NFO 来源的分片
|
||||
// 没有 douban_id/thetvdb_id(MetaTube 刮削出来的那几个才有)。若仍按外部
|
||||
// ID 预先收窄候选集,同番号的其它分片会被 SQL 直接排除,表现就是库里折叠
|
||||
// 出了 N 个版本、详情页却只列出带 ID 的那几个。因此成人条目不预先收窄,
|
||||
// 直接在该库范围内比对版本键。未刮削但文件名带番号的分片同理:同一部片的
|
||||
// 各分片元数据来源不一致(刮削 vs pending),收窄后标题也对不上。
|
||||
var candidates []model.Media
|
||||
narrowed := false
|
||||
hasAdultCode := mediaAdultGroupCode(*media) != "" ||
|
||||
canonicalAdultGroupCode(AdultCodeFromMediaPath(media.Path)) != ""
|
||||
if !hasAdultCode {
|
||||
candidates, narrowed, err = s.repo.Media.ListVersionCandidates(ctx, libraryIDs, *media, 5000)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if !narrowed {
|
||||
candidates, err = s.repo.Media.ListByLibrariesFilteredNoCount(ctx, libraryIDs, 0, 5000, filter)
|
||||
@@ -190,23 +201,21 @@ func (s *MediaService) listVersionSiblings(ctx context.Context, media *model.Med
|
||||
}
|
||||
}
|
||||
s.attachLibraryMetadata(ctx, candidates)
|
||||
// 同番号只要有一个分片被确认为成人内容,尚未刮削的分片也按番号折叠,
|
||||
// 与列表页的分组结果保持一致。
|
||||
vouched := adultCodesVouchedByNSFW(candidates)
|
||||
if vouchKey := adultVouchedGroupKey(*media, vouched); vouchKey != "" {
|
||||
key = vouchKey
|
||||
}
|
||||
matched := make([]model.Media, 0, 4)
|
||||
for _, row := range candidates {
|
||||
if mediaVersionGroupKey(row) == key {
|
||||
if mediaVersionGroupKeyWithAdultVouch(row, vouched) == key {
|
||||
matched = append(matched, row)
|
||||
}
|
||||
}
|
||||
if len(matched) == 0 {
|
||||
return []model.Media{*media}, nil
|
||||
}
|
||||
sort.SliceStable(matched, func(i, j int) bool {
|
||||
if matched[i].ID == media.ID {
|
||||
return true
|
||||
}
|
||||
if matched[j].ID == media.ID {
|
||||
return false
|
||||
}
|
||||
return betterMediaVersion(matched[i], matched[j])
|
||||
})
|
||||
sortMediaVersionsForDisplay(matched)
|
||||
return matched, nil
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ package service
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
@@ -75,35 +77,33 @@ func groupMediaVersions(items []model.Media) []MediaItem {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
// 同一番号只要有一个分片被刮削标记为成人内容,就认定整部片都是成人条目:
|
||||
// 未刮削的分片(nsfw=false)也按番号折叠。否则「一个分片刮削成功、其余仍
|
||||
// pending」时,已刮削的走番号分组、其余落到标题分组,整部片被拆成两张卡。
|
||||
adultCodes := adultCodesVouchedByNSFW(items)
|
||||
type group struct {
|
||||
key string
|
||||
primary model.Media
|
||||
rows []model.Media
|
||||
key string
|
||||
rows []model.Media
|
||||
}
|
||||
groups := make([]group, 0, len(items))
|
||||
byKey := make(map[string]int, len(items))
|
||||
for _, item := range items {
|
||||
key := mediaVersionGroupKey(item)
|
||||
key := mediaVersionGroupKeyWithAdultVouch(item, adultCodes)
|
||||
if key == "" {
|
||||
groups = append(groups, group{primary: item, rows: []model.Media{item}})
|
||||
groups = append(groups, group{rows: []model.Media{item}})
|
||||
continue
|
||||
}
|
||||
if idx, ok := byKey[key]; ok {
|
||||
groups[idx].rows = append(groups[idx].rows, item)
|
||||
if betterMediaVersion(item, groups[idx].primary) {
|
||||
groups[idx].primary = item
|
||||
}
|
||||
continue
|
||||
}
|
||||
byKey[key] = len(groups)
|
||||
groups = append(groups, group{key: key, primary: item, rows: []model.Media{item}})
|
||||
groups = append(groups, group{key: key, rows: []model.Media{item}})
|
||||
}
|
||||
out := make([]MediaItem, 0, len(groups))
|
||||
for _, g := range groups {
|
||||
sort.SliceStable(g.rows, func(i, j int) bool {
|
||||
return betterMediaVersion(g.rows[i], g.rows[j])
|
||||
})
|
||||
item := MediaItem{Media: g.primary}
|
||||
sortMediaVersionsForDisplay(g.rows)
|
||||
item := MediaItem{Media: g.rows[0]}
|
||||
if len(g.rows) > 1 {
|
||||
item.Versions = g.rows
|
||||
}
|
||||
@@ -139,16 +139,65 @@ func GroupEpisodeVersionsForDisplay(items []model.Media) []MediaItem {
|
||||
return grouped
|
||||
}
|
||||
|
||||
func mediaVersionGroupKey(m model.Media) string {
|
||||
// 远程 Emby 挂载条目保持独立,不与其它远程条目或本地条目折叠合并。
|
||||
if IsEmbyRemoteID(m.ID) {
|
||||
return fmt.Sprintf("embyremote:%s", m.ID)
|
||||
}
|
||||
|
||||
// mediaVersionLibraryKey 返回版本身份里使用的库标识。
|
||||
func mediaVersionLibraryKey(m model.Media) string {
|
||||
libKey := strings.ToLower(strings.TrimSpace(m.LibraryID))
|
||||
if libKey == "" {
|
||||
libKey = strings.ToLower(strings.TrimSpace(m.DisplayLibraryID))
|
||||
}
|
||||
return libKey
|
||||
}
|
||||
|
||||
// adultCodesVouchedByNSFW 收集「已被刮削确认为成人内容」的番号。
|
||||
func adultCodesVouchedByNSFW(items []model.Media) map[string]bool {
|
||||
codes := map[string]bool{}
|
||||
for _, item := range items {
|
||||
if !item.NSFW {
|
||||
continue
|
||||
}
|
||||
if code := canonicalAdultGroupCode(AdultCodeFromMediaPath(item.Path)); code != "" {
|
||||
codes[code] = true
|
||||
continue
|
||||
}
|
||||
if code := mediaAdultGroupCode(item); code != "" {
|
||||
codes[code] = true
|
||||
}
|
||||
}
|
||||
return codes
|
||||
}
|
||||
|
||||
// mediaVersionGroupKeyWithAdultVouch 是版本身份的统一入口:番号已被同组分片
|
||||
// 确认时优先按番号折叠,否则沿用 mediaVersionGroupKey 的原判定。
|
||||
func mediaVersionGroupKeyWithAdultVouch(m model.Media, vouched map[string]bool) string {
|
||||
if key := adultVouchedGroupKey(m, vouched); key != "" {
|
||||
return key
|
||||
}
|
||||
return mediaVersionGroupKey(m)
|
||||
}
|
||||
|
||||
// adultVouchedGroupKey 在番号已被同组分片确认时,返回番号分组键;
|
||||
// 其余情况返回空串,表示沿用 mediaVersionGroupKey 的原判定。
|
||||
func adultVouchedGroupKey(m model.Media, vouched map[string]bool) string {
|
||||
if len(vouched) == 0 || m.NSFW {
|
||||
return ""
|
||||
}
|
||||
code := canonicalAdultGroupCode(AdultCodeFromMediaPath(m.Path))
|
||||
if code == "" || !vouched[code] {
|
||||
return ""
|
||||
}
|
||||
libKey := mediaVersionLibraryKey(m)
|
||||
if libKey == "" {
|
||||
libKey = "_"
|
||||
}
|
||||
return fmt.Sprintf("adult:%s:%s", libKey, code)
|
||||
}
|
||||
|
||||
func mediaVersionGroupKey(m model.Media) string { // 远程 Emby 挂载条目保持独立,不与其它远程条目或本地条目折叠合并。
|
||||
if IsEmbyRemoteID(m.ID) {
|
||||
return fmt.Sprintf("embyremote:%s", m.ID)
|
||||
}
|
||||
|
||||
libKey := mediaVersionLibraryKey(m)
|
||||
specialKind := mediaSpecialKind(m.Path)
|
||||
season, episode := m.SeasonNum, m.EpisodeNum
|
||||
if specialKind != "" && specialKind != mediaSpecialTheatrical && episode <= 0 {
|
||||
@@ -193,6 +242,18 @@ func mediaVersionGroupKey(m model.Media) string {
|
||||
return mediaVersionStemGroupKey(m, libKey)
|
||||
}
|
||||
|
||||
// 成人条目按番号折叠。在线刮削(MetaTube)会把番号与 provider「借用」
|
||||
// 存进 douban_id/thetvdb_id,本地 NFO 路径则拿不到这两个字段;一旦按
|
||||
// 外部 ID 分组,同一部片的各个分片就会因为元数据来源不同(在线刮削 vs
|
||||
// 本地 NFO)裂成两张标题相同的卡。番号取自原文件名/路径/标题,与元数据
|
||||
// 来源无关,因此这里统一用番号做版本身份。
|
||||
if code := mediaAdultGroupCode(m); code != "" {
|
||||
if libKey == "" {
|
||||
libKey = "_"
|
||||
}
|
||||
return fmt.Sprintf("adult:%s:%s", libKey, code)
|
||||
}
|
||||
|
||||
switch {
|
||||
case m.TMDbID > 0:
|
||||
if libKey != "" {
|
||||
@@ -267,6 +328,44 @@ func mediaVersionGroupKey(m model.Media) string {
|
||||
return fmt.Sprintf("movie:%s:%d", title, year)
|
||||
}
|
||||
|
||||
// mediaAdultGroupCode 返回成人条目的番号,作为与元数据来源无关的版本身份。
|
||||
//
|
||||
// 只在已经确认是成人条目(NSFW)时才返回番号:番号本身来自原文件名/路径/
|
||||
// 标题,不会随「在线刮削(MetaTube 把番号与 provider 借用进 douban_id/
|
||||
// thetvdb_id)」还是「本地 NFO(不带外部 ID)」而改变,因此两条来源能算出
|
||||
// 同一个键。未标记 NSFW 的条目仍走标题分组,普通影视库里 "The Matrix 1999"
|
||||
// 这类文件名不会被当成番号。
|
||||
func mediaAdultGroupCode(m model.Media) string {
|
||||
if !m.NSFW {
|
||||
return ""
|
||||
}
|
||||
code := firstText(
|
||||
normalizeAdultCode(m.OriginalName),
|
||||
AdultCodeFromMediaPath(m.Path),
|
||||
normalizeAdultCode(m.Title),
|
||||
)
|
||||
if code == "" {
|
||||
return ""
|
||||
}
|
||||
return canonicalAdultGroupCode(code)
|
||||
}
|
||||
|
||||
// canonicalAdultGroupCode 去掉番号数字部分的补零,让 IPVR-00192 与 IPVR-192
|
||||
// 这类同一部片的不同写法落到同一个分组键上。无法解析数字的番号原样返回
|
||||
// (如 FC2-PPV-4701981)。
|
||||
func canonicalAdultGroupCode(code string) string {
|
||||
code = strings.ToUpper(strings.TrimSpace(code))
|
||||
prefix, digits, ok := strings.Cut(code, "-")
|
||||
if !ok {
|
||||
return code
|
||||
}
|
||||
value, err := strconv.Atoi(digits)
|
||||
if err != nil {
|
||||
return code
|
||||
}
|
||||
return prefix + "-" + strconv.Itoa(value)
|
||||
}
|
||||
|
||||
// mediaVersionStemGroupKey 按「库 + 父目录 + 文件词干」折叠多版本
|
||||
// (如 竞女01.mkv.strm 与 竞女01.mp4.strm)。
|
||||
func mediaVersionStemGroupKey(m model.Media, libKey string) string {
|
||||
@@ -300,8 +399,11 @@ func mediaVersionFileStem(name string) string {
|
||||
}
|
||||
|
||||
// MediaVersionLabel 生成版本切换展示名(分辨率 / 容器 / 编码 / 体积 / 文件名)。
|
||||
// 当只有容器等无法区分多版本的信息时,回退到清理后的文件名。
|
||||
func MediaVersionLabel(m model.Media) string {
|
||||
parts := make([]string, 0, 4)
|
||||
isStrm := strings.HasSuffix(strings.ToLower(strings.TrimSpace(m.Path)), ".strm") ||
|
||||
strings.TrimSpace(m.STRMURL) != ""
|
||||
if m.Height > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%dp", m.Height))
|
||||
} else if m.Width > 0 {
|
||||
@@ -317,17 +419,91 @@ func MediaVersionLabel(m model.Media) string {
|
||||
if codec := strings.TrimSpace(m.VideoCodec); codec != "" {
|
||||
parts = append(parts, strings.ToUpper(codec))
|
||||
}
|
||||
if m.SizeBytes > 0 {
|
||||
// STRM 占位体积(通常几十到几百字节)不能区分分片,跳过。
|
||||
placeholderSize := isStrm && m.SizeBytes > 0 && m.SizeBytes < 1024*1024
|
||||
if m.SizeBytes > 0 && !placeholderSize {
|
||||
parts = append(parts, formatMediaSize(m.SizeBytes))
|
||||
}
|
||||
if len(parts) > 0 {
|
||||
if len(parts) > 0 && !mediaVersionLabelIndistinct(parts) {
|
||||
return strings.Join(parts, " · ")
|
||||
}
|
||||
base := filepath.Base(strings.ReplaceAll(strings.TrimSpace(m.Path), "\\", "/"))
|
||||
if base == "" || base == "." {
|
||||
return firstNonEmpty(m.Title, m.OriginalName, m.ID)
|
||||
if cleaned := cleanMediaVersionFilename(m.Path); cleaned != "" {
|
||||
return cleaned
|
||||
}
|
||||
return base
|
||||
return firstNonEmpty(m.Title, m.OriginalName, m.ID)
|
||||
}
|
||||
|
||||
func mediaVersionLabelIndistinct(parts []string) bool {
|
||||
if len(parts) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, part := range parts {
|
||||
upper := strings.ToUpper(strings.TrimSpace(part))
|
||||
if upper == "" || upper == "云端直链" || upper == "STRM" {
|
||||
continue
|
||||
}
|
||||
if _, ok := videoExtensions["."+strings.ToLower(upper)]; ok {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func cleanMediaVersionFilename(path string) string {
|
||||
base := filepath.Base(strings.ReplaceAll(strings.TrimSpace(path), "\\", "/"))
|
||||
if base == "" || base == "." {
|
||||
return ""
|
||||
}
|
||||
stem := mediaFileStem(base)
|
||||
stem = strings.ReplaceAll(stem, "_", " ")
|
||||
stem = strings.ReplaceAll(stem, ".", " ")
|
||||
return strings.Join(strings.Fields(stem), " ")
|
||||
}
|
||||
|
||||
// mediaPartNumber 从路径提取分片序号(part1 / cd2 / -3)。
|
||||
// 裸后缀只认 1–2 位数字,避免把年份 2024 当成 part。
|
||||
func mediaPartNumber(path string) (int, bool) {
|
||||
stem := mediaFileStem(filepath.Base(strings.ReplaceAll(strings.TrimSpace(path), "\\", "/")))
|
||||
if stem == "" {
|
||||
return 0, false
|
||||
}
|
||||
lower := strings.ToLower(stem)
|
||||
for _, re := range mediaPartNumberPatterns {
|
||||
if m := re.FindStringSubmatch(lower); len(m) == 2 {
|
||||
n, err := strconv.Atoi(m[1])
|
||||
if err == nil && n > 0 {
|
||||
return n, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
var mediaPartNumberPatterns = []*regexp.Regexp{
|
||||
regexp.MustCompile(`(?:^|[.\-_])part[\s._-]*(\d{1,3})$`),
|
||||
regexp.MustCompile(`(?:^|[.\-_])(?:cd|disc|disk)[\s._-]*(\d{1,3})$`),
|
||||
regexp.MustCompile(`[-_](\d{1,2})$`),
|
||||
}
|
||||
|
||||
func sortMediaVersionsForDisplay(rows []model.Media) {
|
||||
sort.SliceStable(rows, func(i, j int) bool {
|
||||
return compareMediaVersionsForDisplay(rows[i], rows[j])
|
||||
})
|
||||
}
|
||||
|
||||
// compareMediaVersionsForDisplay:有分片号时按 1→2→3… 升序;否则按画质/体积择优。
|
||||
func compareMediaVersionsForDisplay(a, b model.Media) bool {
|
||||
pa, oka := mediaPartNumber(a.Path)
|
||||
pb, okb := mediaPartNumber(b.Path)
|
||||
if oka && okb {
|
||||
if pa != pb {
|
||||
return pa < pb
|
||||
}
|
||||
} else if oka != okb {
|
||||
return oka
|
||||
}
|
||||
return betterMediaVersion(a, b)
|
||||
}
|
||||
|
||||
func mediaVersionContainerFromPath(path, strmURL string) string {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// 真实场景:同一部片的分片里只有第 4 个被刮削成功(nsfw=true),其余仍是 pending。
|
||||
// 修复前:已刮削的分片走「番号」分组、其余落到「标题」分组,7 个分片会裂成两张卡
|
||||
// (一张只有第 4 个,另一张是其余 6 个)。
|
||||
func TestGroupMediaVersionsKeepsUnscrapedPartsWithScrapedSibling(t *testing.T) {
|
||||
const libID = "lib-1"
|
||||
const dir = "/media/云下载/sivr-270/"
|
||||
|
||||
parts := func(nums ...int) []model.Media {
|
||||
out := make([]model.Media, 0, len(nums))
|
||||
for _, n := range nums {
|
||||
out = append(out, model.Media{
|
||||
LibraryID: libID,
|
||||
Title: "sivr 270",
|
||||
Path: dir + "sivr-270-" + strconv.Itoa(n) + ".strm",
|
||||
Container: "strm",
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
items := parts(1, 2, 3)
|
||||
items = append(items, model.Media{
|
||||
LibraryID: libID,
|
||||
Title: "SIVR-270-【VR】河北彩花×ご奉仕ナース",
|
||||
OriginalName: "SIVR-270",
|
||||
Path: dir + "sivr-270-4.strm",
|
||||
NSFW: true,
|
||||
ScrapeStatus: "matched",
|
||||
Container: "strm",
|
||||
})
|
||||
items = append(items, parts(5, 6, 7)...)
|
||||
|
||||
grouped := GroupMediaVersions(items)
|
||||
if len(grouped) != 1 {
|
||||
paths := make([]string, 0, len(grouped))
|
||||
for _, g := range grouped {
|
||||
paths = append(paths, g.Path)
|
||||
}
|
||||
t.Fatalf("分成 %d 张卡 %v,期望折叠成 1 张", len(grouped), paths)
|
||||
}
|
||||
if len(grouped[0].Versions) != 7 {
|
||||
t.Fatalf("版本数 = %d,期望 7", len(grouped[0].Versions))
|
||||
}
|
||||
}
|
||||
|
||||
// 反向保护:没有任何分片被确认为成人内容时,不能仅凭文件名里的番号形态折叠,
|
||||
// 否则普通媒体也可能被误并进同一张卡。
|
||||
func TestGroupMediaVersionsIgnoresUnvouchedAdultCode(t *testing.T) {
|
||||
items := []model.Media{
|
||||
{LibraryID: "lib-2", Title: "Alpha Movie", Path: "/media/movies/Alpha Movie/ABC-123-1080p.mkv"},
|
||||
{LibraryID: "lib-2", Title: "Beta Movie", Path: "/media/movies/Beta Movie/ABC-123-2160p.mkv"},
|
||||
}
|
||||
grouped := GroupMediaVersions(items)
|
||||
if len(grouped) != 2 {
|
||||
t.Fatalf("分成 %d 张卡,期望 2 张(未被确认成人内容的番号不参与折叠)", len(grouped))
|
||||
}
|
||||
}
|
||||
@@ -442,3 +442,298 @@ func TestMediaVersionLabelUsesContainerAndSize(t *testing.T) {
|
||||
t.Fatalf("unexpected label %q", label)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaPartNumberFromPath(t *testing.T) {
|
||||
cases := []struct {
|
||||
path string
|
||||
want int
|
||||
ok bool
|
||||
}{
|
||||
{"/media/云下载/ipvr00192pl/fbzip.com@ipvr00192.part1.strm", 1, true},
|
||||
{"/media/云下载/ipvr00192pl/fbzip.com@ipvr00192.part2.strm", 2, true},
|
||||
{"/media/云下载/sivr-270/sivr-270-1.strm", 1, true},
|
||||
{"/media/云下载/sivr-270/sivr-270-7.strm", 7, true},
|
||||
{"/media/云下载/fc2/FC2PPV-4701981-cd2.strm", 2, true},
|
||||
{"/media/Movies/Inception.2010.1080p.BluRay.x264.mkv", 0, false},
|
||||
{"/media/Movies/Movie.2024.mkv", 0, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got, ok := mediaPartNumber(tc.path)
|
||||
if ok != tc.ok || got != tc.want {
|
||||
t.Fatalf("mediaPartNumber(%q) = (%d, %v), want (%d, %v)", tc.path, got, ok, tc.want, tc.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupMediaVersionsSortsPartsAscending(t *testing.T) {
|
||||
rows := []model.Media{
|
||||
{
|
||||
Base: model.Base{ID: "p3"},
|
||||
LibraryID: "cloud",
|
||||
Title: "SIVR-270",
|
||||
Path: "/media/云下载/sivr-270/sivr-270-3.strm",
|
||||
Year: 2023,
|
||||
NSFW: true,
|
||||
SizeBytes: 300,
|
||||
},
|
||||
{
|
||||
Base: model.Base{ID: "p1"},
|
||||
LibraryID: "cloud",
|
||||
Title: "SIVR-270",
|
||||
Path: "/media/云下载/sivr-270/sivr-270-1.strm",
|
||||
Year: 2023,
|
||||
NSFW: true,
|
||||
SizeBytes: 100,
|
||||
},
|
||||
{
|
||||
Base: model.Base{ID: "p2"},
|
||||
LibraryID: "cloud",
|
||||
Title: "SIVR-270",
|
||||
Path: "/media/云下载/sivr-270/sivr-270-2.strm",
|
||||
Year: 2023,
|
||||
NSFW: true,
|
||||
SizeBytes: 200,
|
||||
},
|
||||
}
|
||||
grouped := groupMediaVersions(rows)
|
||||
if len(grouped) != 1 || len(grouped[0].Versions) != 3 {
|
||||
t.Fatalf("grouped = %#v", grouped)
|
||||
}
|
||||
want := []string{"p1", "p2", "p3"}
|
||||
for i, id := range want {
|
||||
if grouped[0].Versions[i].ID != id {
|
||||
t.Fatalf("versions[%d] = %q, want %q (order %#v)", i, grouped[0].Versions[i].ID, id, grouped[0].Versions)
|
||||
}
|
||||
}
|
||||
if grouped[0].ID != "p1" {
|
||||
t.Fatalf("primary = %q, want p1 (first part)", grouped[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaVersionLabelFallsBackToFilenameWhenIndistinct(t *testing.T) {
|
||||
label := MediaVersionLabel(model.Media{
|
||||
Title: "SIVR-270-【VR】河北彩花",
|
||||
Path: "/media/云下载/sivr-270/sivr-270-1.strm",
|
||||
SizeBytes: 157,
|
||||
STRMURL: "/api/strm/play/cloud115/video.mp4?pickcode=a",
|
||||
})
|
||||
if !strings.Contains(label, "sivr-270-1") {
|
||||
t.Fatalf("indistinct strm label should fall back to filename, got %q", label)
|
||||
}
|
||||
if strings.EqualFold(label, "MP4") || strings.Contains(label, "云端") {
|
||||
t.Fatalf("should not keep generic-only label %q", label)
|
||||
}
|
||||
}
|
||||
|
||||
// SIVR-270 现场:一部分分片走在线刮削(MetaTube 把番号/provider 借用进
|
||||
// douban_id/thetvdb_id),另一部分只从本地 NFO 拿到标题(没有外部 ID)。
|
||||
// 按外部 ID 分组会把它们裂成两张标题完全相同的卡,必须按番号折成一张。
|
||||
func TestGroupMediaVersionsMergesAdultPartsAcrossMetadataSources(t *testing.T) {
|
||||
online := model.Media{
|
||||
Base: model.Base{ID: "sivr-270-2"},
|
||||
LibraryID: "cloud",
|
||||
Title: "SIVR-270-【VR】河北彩花",
|
||||
OriginalName: "SIVR-270",
|
||||
Path: "/media/云下载/sivr-270/sivr-270-2.strm",
|
||||
Year: 2023,
|
||||
NSFW: true,
|
||||
DoubanID: "SIVR-270",
|
||||
TheTVDBID: "JavBus",
|
||||
ScrapeStatus: "matched",
|
||||
SizeBytes: 200,
|
||||
}
|
||||
fromNFO := model.Media{
|
||||
Base: model.Base{ID: "sivr-270-3"},
|
||||
LibraryID: "cloud",
|
||||
Title: "SIVR-270-【VR】河北彩花",
|
||||
OriginalName: "SIVR-270",
|
||||
Path: "/media/云下载/sivr-270/sivr-270-3.strm",
|
||||
Year: 2023,
|
||||
NSFW: true,
|
||||
ScrapeStatus: "matched",
|
||||
SizeBytes: 100,
|
||||
}
|
||||
|
||||
grouped := groupMediaVersions([]model.Media{online, fromNFO})
|
||||
if len(grouped) != 1 {
|
||||
t.Fatalf("grouped len = %d, want 1: %#v", len(grouped), grouped)
|
||||
}
|
||||
if len(grouped[0].Versions) != 2 {
|
||||
t.Fatalf("versions len = %d, want 2", len(grouped[0].Versions))
|
||||
}
|
||||
if grouped[0].Path != online.Path {
|
||||
t.Fatalf("larger part should stay primary, got %q", grouped[0].Path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupMediaVersionsKeepsDifferentAdultCodesSeparate(t *testing.T) {
|
||||
rows := []model.Media{
|
||||
{
|
||||
Base: model.Base{ID: "sivr-270"},
|
||||
LibraryID: "cloud",
|
||||
Title: "SIVR-270 作品",
|
||||
Path: "/media/云下载/sivr-270/sivr-270-1.strm",
|
||||
Year: 2023,
|
||||
NSFW: true,
|
||||
},
|
||||
{
|
||||
Base: model.Base{ID: "sivr-271"},
|
||||
LibraryID: "cloud",
|
||||
Title: "SIVR-271 作品",
|
||||
Path: "/media/云下载/sivr-271/sivr-271-1.strm",
|
||||
Year: 2023,
|
||||
NSFW: true,
|
||||
},
|
||||
}
|
||||
grouped := groupMediaVersions(rows)
|
||||
if len(grouped) != 2 {
|
||||
t.Fatalf("different adult codes must stay separate: %#v", grouped)
|
||||
}
|
||||
}
|
||||
|
||||
// 同一部片的两种写法:路径里的 IPVR-00192 与刮削回来的 IPVR-192 必须归一。
|
||||
func TestGroupMediaVersionsMergesAdultCodesIgnoringZeroPadding(t *testing.T) {
|
||||
scraped := model.Media{
|
||||
Base: model.Base{ID: "ipvr-192-part2"},
|
||||
LibraryID: "cloud",
|
||||
Title: "IPVR-192-相沢みなみ",
|
||||
OriginalName: "IPVR-192",
|
||||
Path: "/media/云下载/ipvr00192pl/scraped/part2.strm",
|
||||
Year: 2022,
|
||||
NSFW: true,
|
||||
ScrapeStatus: "matched",
|
||||
SizeBytes: 200,
|
||||
}
|
||||
fromPath := model.Media{
|
||||
Base: model.Base{ID: "ipvr-00192-part1"},
|
||||
LibraryID: "cloud",
|
||||
Title: "fbzip com@ipvr00192",
|
||||
Path: "/media/云下载/ipvr00192pl/fbzip.com@ipvr00192.part1.strm",
|
||||
Year: 2022,
|
||||
NSFW: true,
|
||||
SizeBytes: 100,
|
||||
}
|
||||
grouped := groupMediaVersions([]model.Media{scraped, fromPath})
|
||||
if len(grouped) != 1 || len(grouped[0].Versions) != 2 {
|
||||
t.Fatalf("zero padded code must fold into one item: %#v", grouped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupMediaVersionsMergesAdultPartsWithBorrowedMetaTubeIDs(t *testing.T) {
|
||||
// IPVR-00192 现场:两个 part 标题、年份完全一样,但 part2 是走 MetaTube
|
||||
// 刮削的(番号/provider 被借用进 douban_id/thetvdb_id),part1 只有本地
|
||||
// NFO 身份、两个字段为空。旧逻辑按外部 ID 分组会裂成两张卡。
|
||||
part1 := model.Media{
|
||||
Base: model.Base{ID: "ipvr-00192-part1"},
|
||||
LibraryID: "cloud",
|
||||
Title: "IPVR-00192-IPVR-192-【VR】相沢みなみ",
|
||||
OriginalName: "IPVR-00192",
|
||||
Path: "/media/云下载/ipvr00192pl/fbzip.com@ipvr00192.part1.strm",
|
||||
Year: 2022,
|
||||
NSFW: true,
|
||||
ScrapeStatus: "matched",
|
||||
SizeBytes: 100,
|
||||
}
|
||||
part2 := part1
|
||||
part2.ID = "ipvr-00192-part2"
|
||||
part2.Path = "/media/云下载/ipvr00192pl/fbzip.com@ipvr00192.part2.strm"
|
||||
part2.DoubanID = "ipvr00192"
|
||||
part2.TheTVDBID = "JAV321"
|
||||
part2.SizeBytes = 200
|
||||
|
||||
grouped := groupMediaVersions([]model.Media{part1, part2})
|
||||
if len(grouped) != 1 {
|
||||
t.Fatalf("grouped len = %d, want 1: %#v", len(grouped), grouped)
|
||||
}
|
||||
if len(grouped[0].Versions) != 2 {
|
||||
t.Fatalf("versions len = %d, want 2", len(grouped[0].Versions))
|
||||
}
|
||||
if grouped[0].Versions[0].ID != "ipvr-00192-part1" || grouped[0].Versions[1].ID != "ipvr-00192-part2" {
|
||||
t.Fatalf("versions should be part1 then part2, got %#v", grouped[0].Versions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMediaItemListsAdultVersionsAcrossMetadataSources(t *testing.T) {
|
||||
db := newServiceTestDB(t, &model.Library{}, &model.Media{})
|
||||
repos := repository.New(db)
|
||||
lib := model.Library{Name: "云下载", Path: "/media/云下载", Type: "movie", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows := []model.Media{
|
||||
{
|
||||
// 走 MetaTube 刮削的分片:番号/provider 被借用进 douban/thetvdb。
|
||||
LibraryID: lib.ID,
|
||||
Title: "SIVR-270-【VR】河北彩花",
|
||||
OriginalName: "SIVR-270",
|
||||
Path: "/media/云下载/sivr-270/sivr-270-1.strm",
|
||||
Year: 2023,
|
||||
NSFW: true,
|
||||
DoubanID: "SIVR-270",
|
||||
TheTVDBID: "JavBus",
|
||||
ScrapeStatus: "matched",
|
||||
SizeBytes: 200,
|
||||
},
|
||||
{
|
||||
// 只有本地 NFO 身份的分片:两个外部 ID 都为空。
|
||||
LibraryID: lib.ID,
|
||||
Title: "SIVR-270-【VR】河北彩花",
|
||||
OriginalName: "SIVR-270",
|
||||
Path: "/media/云下载/sivr-270/sivr-270-4.strm",
|
||||
Year: 2023,
|
||||
NSFW: true,
|
||||
ScrapeStatus: "matched",
|
||||
SizeBytes: 100,
|
||||
},
|
||||
}
|
||||
if err := db.Create(&rows).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos).
|
||||
SetRuntimeCache(NewRuntimeCacheService(&config.Config{}, zap.NewNop()))
|
||||
item, err := svc.GetMediaItem(t.Context(), rows[0].ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if item == nil || len(item.Versions) != 2 {
|
||||
// 详情页版本列表不能只按外部 ID 收窄,否则没有 ID 的分片会消失。
|
||||
t.Fatalf("versions = %#v, want both parts", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaAdultGroupCodeRequiresNSFW(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
m model.Media
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "普通影片名里的字母+数字不当番号",
|
||||
m: model.Media{Title: "Spider-Man 2008 1080p", Path: "/media/Movies/Spider-Man 2008 1080p.mkv"},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "未识别成人的条目即使文件名像番号也不折叠",
|
||||
m: model.Media{Title: "SIVR-270", Path: "/media/云下载/sivr-270/sivr-270-1.strm"},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "已标记 NSFW 时按番号分组",
|
||||
m: model.Media{NSFW: true, Title: "sivr 270", Path: "/media/云下载/sivr-270/sivr-270-1.strm"},
|
||||
want: "SIVR-270",
|
||||
},
|
||||
{
|
||||
name: "站点前缀里带补零的番号会归一",
|
||||
m: model.Media{NSFW: true, Title: "fbzip com@ipvr00192", Path: "/media/云下载/ipvr00192pl/fbzip.com@ipvr00192.part1.strm"},
|
||||
want: "IPVR-192",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := mediaAdultGroupCode(tc.m); got != tc.want {
|
||||
t.Fatalf("mediaAdultGroupCode = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+85
-15
@@ -18,6 +18,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/zap"
|
||||
@@ -48,9 +49,13 @@ type movieNFO struct {
|
||||
Poster string `xml:"thumb,omitempty"`
|
||||
Fanart string `xml:"fanart,omitempty"`
|
||||
TMDb int `xml:"tmdbid,omitempty"`
|
||||
Genre []string `xml:"genre,omitempty"`
|
||||
Country []string `xml:"country,omitempty"`
|
||||
Language []string `xml:"language,omitempty"`
|
||||
// UniqueIDs 让 sidecar 能把 MeBox 自己用的外部 ID 原样带回来。缺了它,
|
||||
// 「在线刮削 → 写 NFO → 重扫读 NFO」会丢掉 douban/thetvdb(成人条目借用
|
||||
// 这两个字段存番号/provider),同一部片的两条来源就会算出不同的版本键。
|
||||
UniqueIDs []nfoUniqueID `xml:"uniqueid,omitempty"`
|
||||
Genre []string `xml:"genre,omitempty"`
|
||||
Country []string `xml:"country,omitempty"`
|
||||
Language []string `xml:"language,omitempty"`
|
||||
}
|
||||
|
||||
type episodeNFO struct {
|
||||
@@ -71,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 {
|
||||
@@ -167,17 +175,18 @@ func WriteMediaNFO(m *model.Media) (string, error) {
|
||||
}
|
||||
}
|
||||
doc = movieNFO{
|
||||
Title: title,
|
||||
Original: original,
|
||||
Year: m.Year,
|
||||
Plot: m.Overview,
|
||||
Rating: m.Rating,
|
||||
Poster: m.PosterURL,
|
||||
Fanart: m.BackdropURL,
|
||||
TMDb: m.TMDbID,
|
||||
Genre: splitNFOList(m.Genres),
|
||||
Country: splitNFOList(m.Countries),
|
||||
Language: splitNFOList(m.Languages),
|
||||
Title: title,
|
||||
Original: original,
|
||||
Year: m.Year,
|
||||
Plot: m.Overview,
|
||||
Rating: m.Rating,
|
||||
Poster: m.PosterURL,
|
||||
Fanart: m.BackdropURL,
|
||||
TMDb: m.TMDbID,
|
||||
UniqueIDs: nfoExternalUniqueIDs(m),
|
||||
Genre: splitNFOList(m.Genres),
|
||||
Country: splitNFOList(m.Countries),
|
||||
Language: splitNFOList(m.Languages),
|
||||
}
|
||||
}
|
||||
out, err := xml.MarshalIndent(doc, "", " ")
|
||||
@@ -185,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))
|
||||
@@ -202,3 +248,27 @@ func splitNFOList(value string) []string {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// nfoExternalUniqueIDs 把 MeBox 使用的外部 ID 写进 sidecar,供自己重扫时读回
|
||||
// (metadataFromDoc 会按 type 还原成 bangumi/douban/thetvdb 字段)。
|
||||
// TMDb 走 <tmdbid>,单集 NFO 不写唯一 ID:单集 ID 属于单集,写进整剧身份会把
|
||||
// 同一部剧拆成多张卡(见 mergeEpisodeMetadata 的约束说明)。
|
||||
func nfoExternalUniqueIDs(m *model.Media) []nfoUniqueID {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]nfoUniqueID, 0, 3)
|
||||
if m.BangumiID > 0 {
|
||||
out = append(out, nfoUniqueID{Type: "bangumi", Value: strconv.Itoa(m.BangumiID)})
|
||||
}
|
||||
if id := strings.TrimSpace(m.DoubanID); id != "" {
|
||||
out = append(out, nfoUniqueID{Type: "douban", Value: id})
|
||||
}
|
||||
if id := strings.TrimSpace(m.TheTVDBID); id != "" {
|
||||
out = append(out, nfoUniqueID{Type: "thetvdb", Value: id})
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -100,3 +100,51 @@ func TestWriteMediaNFOAdultTitleWithCode(t *testing.T) {
|
||||
t.Fatalf("expected originaltitle with code, got:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteMediaNFORoundTripsExternalUniqueIDs(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mediaPath := filepath.Join(root, "云下载", "sivr-270", "sivr-270-1.strm")
|
||||
if err := os.MkdirAll(filepath.Dir(mediaPath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(mediaPath, []byte("strm"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dst, err := WriteMediaNFO(&model.Media{
|
||||
Title: "SIVR-270-河北彩花",
|
||||
OriginalName: "SIVR-270",
|
||||
Path: mediaPath,
|
||||
Year: 2023,
|
||||
NSFW: true,
|
||||
DoubanID: "SIVR-270",
|
||||
TheTVDBID: "JavBus",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := os.ReadFile(dst)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := string(body)
|
||||
// 成人条目把番号/provider 借用存在 douban/thetvdb 两列;sidecar 必须把它们
|
||||
// 带回来,否则「在线刮削 → 写 NFO → 重扫读 NFO」之后同一部片会被按不同
|
||||
// 分组键拆成多张卡。
|
||||
for _, want := range []string{
|
||||
`<uniqueid type="douban">SIVR-270</uniqueid>`,
|
||||
`<uniqueid type="thetvdb">JavBus</uniqueid>`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("sidecar missing %s, got:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
|
||||
meta, err := ReadLocalMetadata(mediaPath, "", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if meta == nil || meta.DoubanID != "SIVR-270" || meta.TheTVDBID != "JavBus" {
|
||||
t.Fatalf("external ids did not round trip: %#v", meta)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -144,6 +144,85 @@ func (p *ProfileService) SetPinnedLibraryIDs(ctx context.Context, userID string,
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
// GetLibraryTags returns the user's library tag groups, filtered to libraries
|
||||
// the user can still access. Empty tags are kept so an editor does not lose a
|
||||
// tag that was just created.
|
||||
func (p *ProfileService) GetLibraryTags(ctx context.Context, userID string) ([]model.LibraryTagSet, error) {
|
||||
user, err := p.repo.User.FindByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user == nil {
|
||||
return nil, errors.New("user not found")
|
||||
}
|
||||
tags := model.NormalizeLibraryTags(user.DecodeLibraryTags())
|
||||
if len(tags) == 0 {
|
||||
return []model.LibraryTagSet{}, nil
|
||||
}
|
||||
visibility := UserDefaultMediaVisibility(ctx, p.repo, userID)
|
||||
accessible, err := p.accessibleLibraryIDSet(ctx, visibility)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range tags {
|
||||
tags[i].LibraryIDs = filterPinnedLibraryIDs(tags[i].LibraryIDs, accessible)
|
||||
if tags[i].LibraryIDs == nil {
|
||||
tags[i].LibraryIDs = []string{}
|
||||
}
|
||||
}
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
// SetLibraryTags persists the user's library tag groups after dropping
|
||||
// inaccessible libraries. A library belongs to at most one tag: the first tag
|
||||
// that lists it wins, so the saved state always matches the tab UI.
|
||||
func (p *ProfileService) SetLibraryTags(ctx context.Context, userID string, tags []model.LibraryTagSet) ([]model.LibraryTagSet, error) {
|
||||
if userID == "" {
|
||||
return nil, errors.New("missing user id")
|
||||
}
|
||||
user, err := p.repo.User.FindByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user == nil {
|
||||
return nil, errors.New("user not found")
|
||||
}
|
||||
visibility := UserDefaultMediaVisibility(ctx, p.repo, userID)
|
||||
accessible, err := p.accessibleLibraryIDSet(ctx, visibility)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
normalized := model.NormalizeLibraryTags(tags)
|
||||
if len(normalized) > model.MaxLibraryTags {
|
||||
normalized = normalized[:model.MaxLibraryTags]
|
||||
}
|
||||
claimed := make(map[string]struct{})
|
||||
for i := range normalized {
|
||||
filtered := make([]string, 0, len(normalized[i].LibraryIDs))
|
||||
for _, id := range filterPinnedLibraryIDs(normalized[i].LibraryIDs, accessible) {
|
||||
if _, taken := claimed[id]; taken {
|
||||
continue
|
||||
}
|
||||
claimed[id] = struct{}{}
|
||||
filtered = append(filtered, id)
|
||||
}
|
||||
normalized[i].LibraryIDs = filtered
|
||||
}
|
||||
raw, err := model.EncodeLibraryTags(normalized)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := p.repo.User.UpdateFields(ctx, userID, map[string]any{
|
||||
"library_tags": raw,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if normalized == nil {
|
||||
normalized = []model.LibraryTagSet{}
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func (p *ProfileService) accessibleLibraryIDSet(ctx context.Context, visibility MediaVisibility) (map[string]struct{}, error) {
|
||||
libs, err := p.repo.Library.List(ctx)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestProfileLibraryTagsFiltersInaccessibleAndKeepsOneTagPerLibrary(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.User{}, &model.Library{}, &model.EmbyMount{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
svc := NewProfileService(zap.NewNop(), repos)
|
||||
|
||||
user := &model.User{Username: "viewer", PasswordHash: "hash", Role: "user"}
|
||||
if err := repos.User.Create(t.Context(), user); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
libA := &model.Library{Name: "Movies", Path: "/media/movies", Type: "movie", Enabled: true}
|
||||
libB := &model.Library{Name: "TV", Path: "/media/tv", Type: "tv", Enabled: true}
|
||||
libHidden := &model.Library{Name: "Adult", Path: "/media/adult", Type: "movie", Enabled: true}
|
||||
for _, lib := range []*model.Library{libA, libB, libHidden} {
|
||||
if err := repos.Library.Create(t.Context(), lib); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := repos.User.UpdateFields(t.Context(), user.ID, map[string]any{
|
||||
"allowed_library_ids": `["` + libA.ID + `","` + libB.ID + `"]`,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := svc.SetLibraryTags(t.Context(), user.ID, []model.LibraryTagSet{
|
||||
{Name: " 热门 ", LibraryIDs: []string{libA.ID, libHidden.ID, "missing", libA.ID}},
|
||||
{Name: "热门", LibraryIDs: []string{libB.ID, libA.ID}},
|
||||
{Name: "", LibraryIDs: []string{libB.ID}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SetLibraryTags: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("SetLibraryTags returned %d tags, want 1 (%#v)", len(got), got)
|
||||
}
|
||||
if got[0].Name != "热门" {
|
||||
t.Fatalf("tag name = %q, want 热门", got[0].Name)
|
||||
}
|
||||
// libA 已在第一个标签里占位,第二个标签里的 libA 应被丢弃。
|
||||
want := []string{libA.ID, libB.ID}
|
||||
if len(got[0].LibraryIDs) != len(want) {
|
||||
t.Fatalf("tag ids = %v, want %v", got[0].LibraryIDs, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[0].LibraryIDs[i] != want[i] {
|
||||
t.Fatalf("tag ids = %v, want %v", got[0].LibraryIDs, want)
|
||||
}
|
||||
}
|
||||
|
||||
loaded, err := svc.GetLibraryTags(t.Context(), user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLibraryTags: %v", err)
|
||||
}
|
||||
if len(loaded) != 1 || loaded[0].Name != "热门" || len(loaded[0].LibraryIDs) != len(want) {
|
||||
t.Fatalf("GetLibraryTags = %#v, want one tag with %v", loaded, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileLibraryTagsKeepsEmptyTagAndMountedEmby(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.User{}, &model.Library{}, &model.EmbyMount{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
svc := NewProfileService(zap.NewNop(), repos)
|
||||
|
||||
user := &model.User{Username: "viewer", PasswordHash: "hash", Role: "user"}
|
||||
if err := repos.User.Create(t.Context(), user); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mount := &model.EmbyMount{
|
||||
AccountID: "acct-1",
|
||||
RemoteViewID: "view-42",
|
||||
RemoteViewName: "Remote Movies",
|
||||
Enabled: true,
|
||||
}
|
||||
if err := repos.EmbyMount.Create(t.Context(), mount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
remoteID := EncodeEmbyRemoteID(mount.ID, mount.RemoteViewID)
|
||||
|
||||
got, err := svc.SetLibraryTags(t.Context(), user.ID, []model.LibraryTagSet{
|
||||
{Name: "远程", LibraryIDs: []string{remoteID, "embyremote~missing~view"}},
|
||||
{Name: "以后再用", LibraryIDs: []string{}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SetLibraryTags: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("SetLibraryTags = %#v, want 2 tags", got)
|
||||
}
|
||||
if len(got[0].LibraryIDs) != 1 || got[0].LibraryIDs[0] != remoteID {
|
||||
t.Fatalf("remote tag ids = %v, want [%s]", got[0].LibraryIDs, remoteID)
|
||||
}
|
||||
if got[1].Name != "以后再用" || len(got[1].LibraryIDs) != 0 {
|
||||
t.Fatalf("empty tag should survive as-is, got %#v", got[1])
|
||||
}
|
||||
|
||||
loaded, err := svc.GetLibraryTags(t.Context(), user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLibraryTags: %v", err)
|
||||
}
|
||||
if len(loaded) != 2 || len(loaded[1].LibraryIDs) != 0 {
|
||||
t.Fatalf("GetLibraryTags = %#v, want the empty tag preserved", loaded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileLibraryTagsEmptyStateReturnsEmptySlice(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.User{}, &model.Library{}, &model.EmbyMount{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
svc := NewProfileService(zap.NewNop(), repos)
|
||||
|
||||
user := &model.User{Username: "viewer", PasswordHash: "hash", Role: "user"}
|
||||
if err := repos.User.Create(t.Context(), user); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tags, err := svc.GetLibraryTags(t.Context(), user.ID); err != nil || tags == nil || len(tags) != 0 {
|
||||
t.Fatalf("GetLibraryTags = %#v, %v; want empty non-nil slice", tags, err)
|
||||
}
|
||||
if tags, err := svc.SetLibraryTags(t.Context(), user.ID, nil); err != nil || tags == nil || len(tags) != 0 {
|
||||
t.Fatalf("SetLibraryTags(nil) = %#v, %v; want empty non-nil slice", tags, err)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -120,6 +120,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 指向解析成可拉取的直链/本地路径。
|
||||
|
||||
@@ -33,8 +33,9 @@ func (s *StreamService) ServeFileWithCloudMode(w http.ResponseWriter, r *http.Re
|
||||
if !cloudPlaybackModeEnabled(r.Context(), s.repo, cloudMode) {
|
||||
return ErrCloudPlaybackDisabled
|
||||
}
|
||||
// 云盘播放 URL 先规范化为相对路径,免疫扫描时固化的旧 host。
|
||||
target := normalizeCloudPlayTarget(strmURL)
|
||||
// 云盘播放 URL 先规范化为相对路径,免疫扫描时固化的旧 host;
|
||||
// 指向别的 MeBox 实例的地址保持原样,按第三方直链透传。
|
||||
target := normalizeCloudPlayTarget(r.Context(), s.repo, s.cfg, r, strmURL)
|
||||
target = withAuthTokenForInternalRedirect(target, r, PublicServerURL(r.Context(), s.repo, s.cfg))
|
||||
setCloudRedirectNoStore(w)
|
||||
http.Redirect(w, r, absoluteInternalRedirect(target, r), http.StatusFound)
|
||||
|
||||
@@ -1,19 +1,28 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// TestNormalizeCloudPlayTarget 验证存库的云盘播放 URL(可能携带扫描时的
|
||||
// 旧 host)被规范化为相对路径,使 302 始终基于当前请求地址构造。
|
||||
// TestNormalizeCloudPlayTarget 验证存库的云盘播放 URL(可能携带扫描时的旧 host)
|
||||
// 被规范化为相对路径,使 302 始终基于当前请求地址构造。
|
||||
func TestNormalizeCloudPlayTarget(t *testing.T) {
|
||||
ref := "/电影/某部影片 (2024)/movie.mkv"
|
||||
stale := "http://192.168.1.4:9011/api/cloud/play/openlist?ref=" + url.QueryEscape(ref)
|
||||
got := normalizeCloudPlayTarget(stale)
|
||||
req := httptest.NewRequest(http.MethodGet, "http://nas.local:18080/api/stream/media-1", nil)
|
||||
ctx := context.Background()
|
||||
|
||||
// 相对路径:本来就是本机形态,按 provider+ref 重建(保持相对)。
|
||||
relative := "/api/cloud/play/openlist?ref=" + url.QueryEscape(ref)
|
||||
got := normalizeCloudPlayTarget(ctx, nil, nil, req, relative)
|
||||
want := BuildRelativeCloudPlayURL("openlist", ref)
|
||||
if got != want {
|
||||
t.Fatalf("normalizeCloudPlayTarget = %q, want %q", got, want)
|
||||
t.Fatalf("normalizeCloudPlayTarget(relative) = %q, want %q", got, want)
|
||||
}
|
||||
parsed, err := url.Parse(got)
|
||||
if err != nil {
|
||||
@@ -26,16 +35,83 @@ func TestNormalizeCloudPlayTarget(t *testing.T) {
|
||||
t.Fatalf("ref round-trip failed: %q", parsed.Query().Get("ref"))
|
||||
}
|
||||
|
||||
strmStale := "http://bwg.linkmy.fun:1314/api/strm/play/cloud115/video.mkv?acct=abc&pickcode=123"
|
||||
gotStrm := normalizeCloudPlayTarget(strmStale)
|
||||
// 绝对地址但 host 就是当前请求 host:宿主切换过(开发机扫描 → 部署)
|
||||
// 之后仍要能当作本机地址处理。
|
||||
sameHost := "http://nas.local:18080/api/strm/play/cloud115/video.mkv?acct=abc&pickcode=123"
|
||||
gotStrm := normalizeCloudPlayTarget(ctx, nil, nil, req, sameHost)
|
||||
wantStrm := "/api/strm/play/cloud115/video.mkv?acct=abc&pickcode=123"
|
||||
if gotStrm != wantStrm {
|
||||
t.Fatalf("normalizeCloudPlayTarget(strm) = %q, want %q", gotStrm, wantStrm)
|
||||
t.Fatalf("normalizeCloudPlayTarget(same host) = %q, want %q", gotStrm, wantStrm)
|
||||
}
|
||||
|
||||
// 非云盘播放 URL 保持原样(WebDAV/直链等)。
|
||||
passthrough := "https://dav.example.com/media/file.mkv"
|
||||
if got := normalizeCloudPlayTarget(passthrough); got != passthrough {
|
||||
if got := normalizeCloudPlayTarget(ctx, nil, nil, req, passthrough); got != passthrough {
|
||||
t.Fatalf("non-cloud target should pass through, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeCloudPlayTargetKeepsForeignInstanceURL 验证别的 MeBox /
|
||||
// MediaStationGo 实例生成的 .strm 内容不被本机账号解析,而是按第三方直链透传。
|
||||
func TestNormalizeCloudPlayTargetKeepsForeignInstanceURL(t *testing.T) {
|
||||
svc := testStrmService(t)
|
||||
ctx := context.Background()
|
||||
// 本机自己的 115 账号(strm.base_url 由 testStrmService 设为 http://test.local:8096)。
|
||||
own := &model.StrmAccount{
|
||||
Base: model.Base{ID: "acct-own-115"},
|
||||
Name: "own",
|
||||
Provider: model.StrmProvider115,
|
||||
Enabled: true,
|
||||
}
|
||||
if err := svc.repo.StrmAccount.Create(ctx, own); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "http://mebox.local/api/stream/media-1", nil)
|
||||
|
||||
// host 不认识 + acct 不是本机账号 → 另一个实例的 .strm,原样透传。
|
||||
foreign := "http://other-mebox.example:18080/api/strm/play/cloud115/video.mkv?acct=acct-other&pickcode=xyz"
|
||||
if got := normalizeCloudPlayTarget(ctx, svc.repo, nil, req, foreign); got != foreign {
|
||||
t.Fatalf("foreign instance url should pass through, got %q", got)
|
||||
}
|
||||
|
||||
// host 不认识但 acct 是本机账号 → 本机换了域名/IP 的老 .strm,仍要认领。
|
||||
staleOwn := "http://192.168.1.4:9011/api/strm/play/cloud115/video.mkv?acct=acct-own-115&pickcode=123"
|
||||
wantStale := "/api/strm/play/cloud115/video.mkv?acct=acct-own-115&pickcode=123"
|
||||
if got := normalizeCloudPlayTarget(ctx, svc.repo, nil, req, staleOwn); got != wantStale {
|
||||
t.Fatalf("own acct on stale host = %q, want %q", got, wantStale)
|
||||
}
|
||||
|
||||
// acct 撞上本机账号 ID 但账号类型与路径 provider 不一致:不算本机。
|
||||
wrongProvider := "http://other-mebox.example/api/strm/play/openlist/video.mkv?acct=acct-own-115&pickcode=123"
|
||||
if got := normalizeCloudPlayTarget(ctx, svc.repo, nil, req, wrongProvider); got != wrongProvider {
|
||||
t.Fatalf("provider mismatch should pass through, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeCloudPlayTargetLegacyCloudURL 旧格式 /api/cloud/play(不带 acct)
|
||||
// 无法凭账号 ID 判断归属:本机配了该类型账号就按本机处理(保住老固化地址的可
|
||||
// 播放性),完全没配才透传。
|
||||
func TestNormalizeCloudPlayTargetLegacyCloudURL(t *testing.T) {
|
||||
svc := testStrmService(t)
|
||||
ctx := context.Background()
|
||||
req := httptest.NewRequest(http.MethodGet, "http://mebox.local/api/stream/media-1", nil)
|
||||
ref := "/Movies/Movie.mkv"
|
||||
legacy := "http://old-host:9011/api/cloud/play/openlist?ref=" + url.QueryEscape(ref)
|
||||
|
||||
// 本机没有 openlist 账号 → 不是本机地址。
|
||||
if got := normalizeCloudPlayTarget(ctx, svc.repo, nil, req, legacy); got != legacy {
|
||||
t.Fatalf("legacy url without local provider should pass through, got %q", got)
|
||||
}
|
||||
|
||||
if err := svc.repo.StrmAccount.Create(ctx, &model.StrmAccount{
|
||||
Base: model.Base{ID: "acct-openlist"},
|
||||
Name: "openlist",
|
||||
Provider: model.StrmProviderOpenList,
|
||||
Enabled: true,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := normalizeCloudPlayTarget(ctx, svc.repo, nil, req, legacy); got != BuildRelativeCloudPlayURL("openlist", ref) {
|
||||
t.Fatalf("legacy url with local provider = %q, want relative rebuild", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
// normalizeCloudPlayTarget 把存库的云盘播放 URL 规范化为相对路径。
|
||||
@@ -11,28 +15,197 @@ import (
|
||||
// STRMURL 是扫描时根据当时的 server_url/请求地址生成并固化进数据库的。
|
||||
// 在 Windows 开发机上扫描、再部署到 Docker(或更换了内网 IP/域名)后,
|
||||
// 这些绝对 URL 会指向已失效的旧地址,第三方播放器跟随 302 就会拿到
|
||||
// 连接失败/404。这里只要能从 URL 中解析出 provider+ref,就重建为相对
|
||||
// /api/cloud/play 路径,由 absoluteInternalRedirect 基于「当前请求」补全
|
||||
// host,从而对历史脏数据免疫。
|
||||
func normalizeCloudPlayTarget(raw string) string {
|
||||
// 连接失败/404。所以只要确认这个地址「是本机自己生成的」,就从 URL 中解析出
|
||||
// provider+ref,重建为相对 /api/cloud/play 或 /api/strm/play 路径,由
|
||||
// absoluteInternalRedirect 基于「当前请求」补全 host,从而对历史脏数据免疫。
|
||||
//
|
||||
// 反过来,指向**别的 MeBox / MediaStationGo 实例**的地址不能按本机账号解析:
|
||||
// 别人 .strm 里的 acct 是他那台机器的账号 ID,拿到本机来查只会得到
|
||||
// 「网盘账号不存在」。这种地址按普通第三方直链原样透传,让客户端跟着 302 去
|
||||
// 对方实例取流(/api/strm/play 是公开端点,不需要本机凭据),或直接去对方的
|
||||
// CDN 直链。归属判断见 isInternalPlaybackTarget。
|
||||
func normalizeCloudPlayTarget(ctx context.Context, repo *repository.Container, cfg *config.Config, r *http.Request, raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return raw
|
||||
}
|
||||
if !isInternalPlaybackTarget(ctx, repo, cfg, r, raw) {
|
||||
return raw
|
||||
}
|
||||
if typ, ref, ok := parseCloudMediaPlaybackURL(raw); ok {
|
||||
return BuildRelativeCloudPlayURL(typ, ref)
|
||||
}
|
||||
if u, err := url.Parse(raw); err == nil {
|
||||
path := strings.ToLower(u.Path)
|
||||
if strings.HasPrefix(path, "/api/strm/play/") || strings.HasPrefix(path, "/api/cloud/play/") || strings.HasPrefix(path, "/api/stream/") {
|
||||
u.Scheme = ""
|
||||
u.Host = ""
|
||||
return u.String()
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
path := strings.ToLower(u.Path)
|
||||
if strings.HasPrefix(path, "/api/strm/play/") || strings.HasPrefix(path, "/api/cloud/play/") || strings.HasPrefix(path, "/api/stream/") {
|
||||
u.Scheme = ""
|
||||
u.Host = ""
|
||||
return u.String()
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// isInternalPlaybackTarget 判断播放地址能否按「本机自己的云盘播放地址」处理
|
||||
// (相对化 + 用本机账号解析)。判断顺序:
|
||||
//
|
||||
// 1. 相对路径一定是本机存库的常规形态;
|
||||
// 2. 绝对地址的 host 与本机配置的 strm.base_url、各同步目录覆盖的 base_url、
|
||||
// 当前请求 host 之一相同 → 就是本机(老 .strm 里固化的旧 host 属于这一类);
|
||||
// 3. host 对不上时,再看地址里带的网盘账号 ID 是不是本机账号:MeBox /
|
||||
// MediaStationGo 会把本机账号 ID 写进 acct,而账号 ID 由各实例自行生成,
|
||||
// 跨实例几乎不可能撞号。这条用于「换了域名/IP 之后」认领自己的老 .strm。
|
||||
//
|
||||
// 三条都不满足(典型是另一台 MeBox 生成的 .strm)→ 视为第三方直链。
|
||||
func isInternalPlaybackTarget(ctx context.Context, repo *repository.Container, cfg *config.Config, r *http.Request, raw string) bool {
|
||||
u, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || u == nil {
|
||||
return false
|
||||
}
|
||||
if u.Host == "" {
|
||||
return true
|
||||
}
|
||||
if !isPlaybackAPIPath(u.Path) {
|
||||
return false
|
||||
}
|
||||
if matchesLocalPlaybackHost(ctx, repo, cfg, r, u) {
|
||||
return true
|
||||
}
|
||||
return localAccountOwnsPlaybackTarget(ctx, repo, u)
|
||||
}
|
||||
|
||||
// isPlaybackAPIPath 判断路径是不是本服务自己的播放端点。
|
||||
func isPlaybackAPIPath(path string) bool {
|
||||
lower := strings.ToLower(strings.TrimSpace(path))
|
||||
for _, prefix := range []string{"/api/strm/play/", "/api/cloud/play/", "/api/stream/"} {
|
||||
if strings.HasPrefix(lower, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// matchesLocalPlaybackHost 判断绝对播放地址的 host 是否就是本机。
|
||||
func matchesLocalPlaybackHost(ctx context.Context, repo *repository.Container, cfg *config.Config, r *http.Request, u *url.URL) bool {
|
||||
if u == nil {
|
||||
return false
|
||||
}
|
||||
target := canonicalHostPort(u)
|
||||
if target == "" {
|
||||
return false
|
||||
}
|
||||
for _, base := range localPlaybackBaseURLs(ctx, repo, cfg, r) {
|
||||
parsed, err := url.Parse(base)
|
||||
if err != nil || parsed == nil {
|
||||
continue
|
||||
}
|
||||
if canonicalHostPort(parsed) == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// localPlaybackBaseURLs 汇总本机的播放基地址:管理员配置的公网地址 /
|
||||
// strm.base_url、每条 STRM 同步目录单独覆盖的 base_url,以及当前请求的 host
|
||||
// (含反向代理头)。请求 host 也要算进来:没有配 base_url 时 .strm 里固化的
|
||||
// 就是请求地址。
|
||||
func localPlaybackBaseURLs(ctx context.Context, repo *repository.Container, cfg *config.Config, r *http.Request) []string {
|
||||
bases := make([]string, 0, 4)
|
||||
if base := PublicServerURL(ctx, repo, cfg); base != "" {
|
||||
bases = append(bases, base)
|
||||
}
|
||||
if repo != nil && repo.StrmSyncPath != nil {
|
||||
if paths, err := repo.StrmSyncPath.List(ctx); err == nil {
|
||||
for i := range paths {
|
||||
if override := strings.TrimSpace(paths[i].StrmBaseURL); override != "" {
|
||||
bases = append(bases, override)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if r != nil {
|
||||
if host := strings.TrimSpace(r.Host); host != "" {
|
||||
bases = append(bases, "//"+host)
|
||||
}
|
||||
for _, header := range []string{"X-Forwarded-Host", "X-Original-Host"} {
|
||||
if host := strings.TrimSpace(r.Header.Get(header)); host != "" {
|
||||
bases = append(bases, "//"+host)
|
||||
}
|
||||
}
|
||||
}
|
||||
return bases
|
||||
}
|
||||
|
||||
// canonicalHostPort 归一化 host[:port]:小写、忽略默认端口(http 80 / https 443)。
|
||||
func canonicalHostPort(u *url.URL) string {
|
||||
if u == nil {
|
||||
return ""
|
||||
}
|
||||
host := strings.ToLower(strings.TrimSpace(u.Hostname()))
|
||||
if host == "" {
|
||||
return ""
|
||||
}
|
||||
port := strings.TrimSpace(u.Port())
|
||||
if port == "" {
|
||||
return host
|
||||
}
|
||||
scheme := strings.ToLower(strings.TrimSpace(u.Scheme))
|
||||
if (scheme == "http" || scheme == "") && port == "80" {
|
||||
return host
|
||||
}
|
||||
if scheme == "https" && port == "443" {
|
||||
return host
|
||||
}
|
||||
return host + ":" + port
|
||||
}
|
||||
|
||||
// localAccountOwnsPlaybackTarget 用「地址里带的网盘账号是不是本机的」判断播放
|
||||
// 地址归属。账号 ID 跨实例不会撞号,因此比 host 更可靠:
|
||||
//
|
||||
// - /api/strm/play/{provider}/video{ext}?acct=…:acct 能在本机查到、且账号类型
|
||||
// 与路径里的 provider 一致,才认作本机地址;
|
||||
// - /api/cloud/play/{provider}?ref=…(旧格式,不带 acct):只能退化为「本机是否
|
||||
// 配置了该类型账号」,配置了才按本机处理,保持老版本固化地址的可播放性。
|
||||
func localAccountOwnsPlaybackTarget(ctx context.Context, repo *repository.Container, u *url.URL) bool {
|
||||
if repo == nil || repo.StrmAccount == nil || u == nil {
|
||||
return false
|
||||
}
|
||||
segments := strings.Split(strings.Trim(strings.TrimSpace(u.Path), "/"), "/")
|
||||
if len(segments) < 4 || !strings.EqualFold(segments[0], "api") || !strings.EqualFold(segments[2], "play") {
|
||||
return false
|
||||
}
|
||||
provider := strings.TrimSpace(segments[3])
|
||||
if provider == "" {
|
||||
return false
|
||||
}
|
||||
switch strings.ToLower(segments[1]) {
|
||||
case "strm":
|
||||
accountID := strings.TrimSpace(u.Query().Get("acct"))
|
||||
if accountID == "" {
|
||||
return false
|
||||
}
|
||||
account, err := repo.StrmAccount.FindByID(ctx, accountID)
|
||||
if err != nil || account == nil {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(strings.TrimSpace(account.Provider), provider)
|
||||
case "cloud":
|
||||
accounts, err := repo.StrmAccount.List(ctx)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for i := range accounts {
|
||||
if strings.EqualFold(strings.TrimSpace(accounts[i].Provider), provider) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// BuildRelativeCloudPlayURL 构造相对的云盘播放 API 路径。
|
||||
func BuildRelativeCloudPlayURL(typ, ref string) string {
|
||||
return "/api/cloud/play/" + url.PathEscape(strings.TrimSpace(typ)) + "?" + url.Values{"ref": []string{ref}}.Encode()
|
||||
|
||||
@@ -216,6 +216,78 @@ func TestServeFileRedirectsLocalSTRMFileTargetByDefault(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 别的 MeBox / MediaStationGo 实例生成的 .strm:里面的 acct 是对方实例的账号,
|
||||
// 本机不能拿自己的账号去解析,直接把 302 透传给客户端,由客户端去对方实例取流。
|
||||
func TestServeFilePassesThroughForeignInstanceSTRMURL(t *testing.T) {
|
||||
repos := newStreamTestRepo(t)
|
||||
target := "http://other-mebox.example:18080/api/strm/play/cloud115/video.mkv?acct=other-acct&pickcode=xyz"
|
||||
if err := repos.DB.Create(&model.Media{
|
||||
Base: model.Base{ID: "foreign-strm"},
|
||||
Title: "Foreign STRM",
|
||||
Path: "D:/media/Foreign.strm",
|
||||
Container: "strm",
|
||||
STRMURL: target,
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := NewStreamService(&config.Config{}, zap.NewNop(), repos, nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "http://nas.local:18080/api/stream/foreign-strm?token=jwt123", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
if err := svc.ServeFile(w, req, "foreign-strm"); err != nil {
|
||||
t.Fatalf("foreign instance strm url should be passed through: %v", err)
|
||||
}
|
||||
if w.Code != http.StatusFound {
|
||||
t.Fatalf("status = %d, want 302", w.Code)
|
||||
}
|
||||
loc := w.Header().Get("Location")
|
||||
if loc != target {
|
||||
t.Fatalf("Location = %q, want untouched %q", loc, target)
|
||||
}
|
||||
if strings.Contains(loc, "jwt123") {
|
||||
t.Fatalf("foreign instance url must not receive our auth token, got %q", loc)
|
||||
}
|
||||
}
|
||||
|
||||
// 本机自己生成的 .strm 在换了域名/IP 之后仍要认领:host 对不上,但 acct 是本机
|
||||
// 网盘账号,于是按当前请求 host 相对化,保持可播放。
|
||||
func TestServeFileRealignsOwnSTRMURLOtherHost(t *testing.T) {
|
||||
repos := repository.New(newServiceTestDB(t, &model.Media{}, &model.Setting{}, &model.StrmAccount{}))
|
||||
if err := repos.StrmAccount.Create(t.Context(), &model.StrmAccount{
|
||||
Base: model.Base{ID: "own-acct"},
|
||||
Name: "own",
|
||||
Provider: model.StrmProvider115,
|
||||
Enabled: true,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repos.DB.Create(&model.Media{
|
||||
Base: model.Base{ID: "own-strm"},
|
||||
Title: "Own STRM",
|
||||
Path: "D:/media/Own.strm",
|
||||
Container: "strm",
|
||||
STRMURL: "http://old-host:9011/api/strm/play/cloud115/video.mkv?acct=own-acct&pickcode=123",
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := NewStreamService(&config.Config{}, zap.NewNop(), repos, nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "http://nas.local:18080/api/stream/own-strm?token=jwt123", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
if err := svc.ServeFile(w, req, "own-strm"); err != nil {
|
||||
t.Fatalf("own strm url on a stale host should still play: %v", err)
|
||||
}
|
||||
if w.Code != http.StatusFound {
|
||||
t.Fatalf("status = %d, want 302", w.Code)
|
||||
}
|
||||
loc := w.Header().Get("Location")
|
||||
if !strings.HasPrefix(loc, "http://nas.local:18080/api/strm/play/cloud115/video.mkv?") ||
|
||||
!strings.Contains(loc, "acct=own-acct") ||
|
||||
!strings.Contains(loc, "pickcode=123") {
|
||||
t.Fatalf("own strm url should be realigned to current host, got %q", loc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloudPlaybackModeUsesExplicitModeBeforeLegacySTRMFlag(t *testing.T) {
|
||||
repos := newStreamTestRepo(t)
|
||||
if got := CloudPlaybackMode(t.Context(), repos); got != CloudPlaybackModeRedirectProxy {
|
||||
|
||||
+106
-12
@@ -121,7 +121,7 @@ func (s *StrmService) resolveLocalPlay(ctx context.Context, rawPath string) (*St
|
||||
// 支持:
|
||||
// - /api/strm/play/{provider}/video{ext}?acct=..&pickcode=.. (常规格式,含账号)
|
||||
// - /api/cloud/play/{type}?ref=.. (旧格式,无账号 → 取该类型第一个启用账号)
|
||||
// - 绝对 http(s) 链接(直接透传)
|
||||
// - 绝对 http(s) 链接(直接透传,包含别的 MeBox / MediaStationGo 实例的播放端点)
|
||||
// - 其余协议(webdav:// 等)返回错误,由调用方决定是否静默跳过
|
||||
func (s *StrmService) ResolvePlayTarget(ctx context.Context, raw string) (*StrmPlayResult, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
@@ -132,6 +132,17 @@ func (s *StrmService) ResolvePlayTarget(ctx context.Context, raw string) (*StrmP
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析播放目标失败: %w", err)
|
||||
}
|
||||
// 别的 MeBox / MediaStationGo 实例的播放端点:本机没有对应账号,按普通外部
|
||||
// 直链处理——客户端与 ffmpeg 直接跟随 302 去对方实例取流(/api/strm/play 是
|
||||
// 公开端点,不需要本机凭据),不能拿本机账号去查别人的 pickcode。
|
||||
if isPlaybackAPIPath(u.Path) && !s.isLocalPlaybackTarget(ctx, raw) {
|
||||
switch strings.ToLower(strings.TrimSpace(u.Scheme)) {
|
||||
case "http", "https":
|
||||
return &StrmPlayResult{RedirectURL: raw}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("不支持的播放目标协议: %s", u.Scheme)
|
||||
}
|
||||
}
|
||||
lowerPath := strings.ToLower(u.Path)
|
||||
switch {
|
||||
case strings.HasPrefix(lowerPath, "/api/strm/play/"):
|
||||
@@ -156,6 +167,16 @@ func (s *StrmService) ResolvePlayTarget(ctx context.Context, raw string) (*StrmP
|
||||
}
|
||||
}
|
||||
|
||||
// isLocalPlaybackTarget 报告播放地址是否属于本机。这里没有 HTTP 请求上下文,
|
||||
// 「本机」由 strm.base_url / 各同步目录覆盖的 base_url / 本机网盘账号共同界定
|
||||
// (见 isInternalPlaybackTarget)。
|
||||
func (s *StrmService) isLocalPlaybackTarget(ctx context.Context, raw string) bool {
|
||||
if s == nil {
|
||||
return true
|
||||
}
|
||||
return isInternalPlaybackTarget(ctx, s.repo, s.cfg, nil, raw)
|
||||
}
|
||||
|
||||
// firstEnabledAccountOf 返回指定提供方第一个凭据可用的启用账号。
|
||||
func (s *StrmService) firstEnabledAccountOf(ctx context.Context, provider string) (*model.StrmAccount, error) {
|
||||
accounts, err := s.repo.StrmAccount.List(ctx)
|
||||
@@ -174,38 +195,111 @@ func (s *StrmService) firstEnabledAccountOf(ctx context.Context, provider string
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// ErrStrmProxyNotApplicable 表示该媒体没有需要服务端转发的直链(本地文件本身同源)。
|
||||
var ErrStrmProxyNotApplicable = errors.New("strm proxy not applicable")
|
||||
|
||||
// ProxyMediaDirect 把媒体行的网盘/STRM 直链解析为真实地址后由服务端反向代理给
|
||||
// 客户端,让浏览器拿到「同源」数据。画质与原文件完全一致,不触发任何转码。
|
||||
//
|
||||
// 用途:VR 全景播放要把视频帧读进 WebGL 纹理,而跨域直链(网盘 302 跳到 CDN)
|
||||
// 在浏览器里属于被污染的资源,WebGL 读取会抛 SecurityError;把流量经服务端转发
|
||||
// 是「原画 + VR」唯一可行的办法。
|
||||
func (s *StrmService) ProxyMediaDirect(ctx context.Context, w http.ResponseWriter, r *http.Request, m *model.Media) error {
|
||||
if s == nil || m == nil {
|
||||
return ErrStrmProxyNotApplicable
|
||||
}
|
||||
raw := strings.TrimSpace(m.STRMURL)
|
||||
if raw == "" {
|
||||
path := strings.TrimSpace(m.Path)
|
||||
if !strings.HasSuffix(strings.ToLower(path), ".strm") {
|
||||
return ErrStrmProxyNotApplicable
|
||||
}
|
||||
target, err := readLocalSTRMTarget(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw = strings.TrimSpace(target)
|
||||
}
|
||||
if raw == "" {
|
||||
return ErrStrmProxyNotApplicable
|
||||
}
|
||||
result, err := s.ResolvePlayTarget(ctx, raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch {
|
||||
case result.Link != nil && result.Link.URL != "":
|
||||
return s.ProxyDirect(ctx, w, r, result.Link)
|
||||
case result.RedirectURL != "":
|
||||
return s.ProxyDirect(ctx, w, r, &cloud.DirectLink{URL: result.RedirectURL})
|
||||
default:
|
||||
// 本地文件(LocalPath)由静态文件处理器提供,本身就是同源。
|
||||
return ErrStrmProxyNotApplicable
|
||||
}
|
||||
}
|
||||
|
||||
// ProxyDirect 反向代理渲染直链内容(保留 Range 请求头以支持拖动播放)。
|
||||
func (s *StrmService) ProxyDirect(ctx context.Context, w http.ResponseWriter, r *http.Request, link *cloud.DirectLink) error {
|
||||
if link == nil || link.URL == "" {
|
||||
return errors.New("空直链")
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, link.URL, nil)
|
||||
method := http.MethodGet
|
||||
if r != nil && r.Method == http.MethodHead {
|
||||
method = http.MethodHead
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, link.URL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for k, v := range link.Headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
if rangeHeader := r.Header.Get("Range"); rangeHeader != "" {
|
||||
req.Header.Set("Range", rangeHeader)
|
||||
if r != nil {
|
||||
if rangeHeader := r.Header.Get("Range"); rangeHeader != "" {
|
||||
req.Header.Set("Range", rangeHeader)
|
||||
}
|
||||
// 部分网盘直链按 UA 防盗链;解析时未绑定 UA 的直链沿用浏览器 UA 更稳。
|
||||
if ua := strings.TrimSpace(r.Header.Get("User-Agent")); ua != "" && req.Header.Get("User-Agent") == "" {
|
||||
req.Header.Set("User-Agent", ua)
|
||||
}
|
||||
}
|
||||
resp, err := s.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
for _, header := range []string{"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", "ETag"} {
|
||||
for _, header := range []string{"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", "ETag", "Last-Modified"} {
|
||||
if value := resp.Header.Get(header); value != "" {
|
||||
w.Header().Set(header, value)
|
||||
}
|
||||
}
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
} else {
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
// 原样透传上游状态码:Range 请求必须回 206,改写成 200 会让浏览器误判
|
||||
// 响应长度,拖动进度条时反复重新拉流。
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
if resp.StatusCode != http.StatusPartialContent && resp.StatusCode != http.StatusOK {
|
||||
return nil
|
||||
}
|
||||
if resp.StatusCode == http.StatusPartialContent || resp.StatusCode == http.StatusOK {
|
||||
_, _ = io.Copy(w, resp.Body)
|
||||
if method == http.MethodHead {
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
// 边转发边 flush,避免大体积视频被 net/http 的写缓冲切成一段段卡顿。
|
||||
writer := io.Writer(w)
|
||||
if flusher, ok := w.(http.Flusher); ok {
|
||||
writer = &flushWriter{writer: w, flusher: flusher}
|
||||
}
|
||||
_, err = io.Copy(writer, resp.Body)
|
||||
return err
|
||||
}
|
||||
|
||||
type flushWriter struct {
|
||||
writer io.Writer
|
||||
flusher http.Flusher
|
||||
}
|
||||
|
||||
func (f *flushWriter) Write(p []byte) (int, error) {
|
||||
n, err := f.writer.Write(p)
|
||||
if f.flusher != nil {
|
||||
f.flusher.Flush()
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// 同源转发必须原样保留 Range 语义:把 206 改写成 200 会让浏览器误判响应长度,
|
||||
// 拖动进度条时反复重新拉流。
|
||||
func TestProxyMediaDirectForwardsRangeAndStatus(t *testing.T) {
|
||||
var gotRange string
|
||||
var gotUA string
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotRange = r.Header.Get("Range")
|
||||
gotUA = r.Header.Get("User-Agent")
|
||||
w.Header().Set("Content-Type", "video/mp4")
|
||||
w.Header().Set("Accept-Ranges", "bytes")
|
||||
w.Header().Set("Content-Range", "bytes 10-19/100")
|
||||
w.WriteHeader(http.StatusPartialContent)
|
||||
_, _ = w.Write([]byte("0123456789"))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
svc := testStrmService(t)
|
||||
media := &model.Media{STRMURL: upstream.URL + "/video.mp4"}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/stream/media-1?proxy=1", nil)
|
||||
req.Header.Set("Range", "bytes=10-19")
|
||||
req.Header.Set("User-Agent", "MeBoxTest/1.0")
|
||||
|
||||
if err := svc.ProxyMediaDirect(context.Background(), rec, req, media); err != nil {
|
||||
t.Fatalf("ProxyMediaDirect: %v", err)
|
||||
}
|
||||
if rec.Code != http.StatusPartialContent {
|
||||
t.Fatalf("status = %d, want 206", rec.Code)
|
||||
}
|
||||
if gotRange != "bytes=10-19" {
|
||||
t.Fatalf("upstream Range = %q, want bytes=10-19", gotRange)
|
||||
}
|
||||
if gotUA != "MeBoxTest/1.0" {
|
||||
t.Fatalf("upstream User-Agent = %q, want MeBoxTest/1.0", gotUA)
|
||||
}
|
||||
if rec.Header().Get("Content-Range") != "bytes 10-19/100" {
|
||||
t.Fatalf("Content-Range = %q", rec.Header().Get("Content-Range"))
|
||||
}
|
||||
if rec.Body.String() != "0123456789" {
|
||||
t.Fatalf("body = %q", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 本地文件本身就是同源资源,不应该被代理(调用方按原静态文件逻辑处理)。
|
||||
func TestProxyMediaDirectSkipsLocalFile(t *testing.T) {
|
||||
svc := testStrmService(t)
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "movie.mkv")
|
||||
writeFile(t, path, "not-a-video")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/stream/media-1?proxy=1", nil)
|
||||
err := svc.ProxyMediaDirect(context.Background(), rec, req, &model.Media{Path: path})
|
||||
if !errors.Is(err, ErrStrmProxyNotApplicable) {
|
||||
t.Fatalf("err = %v, want ErrStrmProxyNotApplicable", err)
|
||||
}
|
||||
if rec.Body.Len() != 0 {
|
||||
t.Fatalf("local file must not be proxied, body = %q", rec.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -746,7 +746,10 @@ func TestHandleMetaSha1Identity(t *testing.T) {
|
||||
|
||||
// fakeRemoteProvider 是 walkRemote 并发遍历的假提供方:返回一棵固定目录树,
|
||||
// 并记录每个目录被 List 的次数,用于验证并发遍历无漏目录、无重复目录。
|
||||
// walkRemote 会并发调用 List,因此计数必须加锁:此前直接写 map 会偶发
|
||||
// "fatal error: concurrent map writes",把整个测试进程带走。
|
||||
type fakeRemoteProvider struct {
|
||||
mu sync.Mutex
|
||||
listed map[string]int
|
||||
}
|
||||
|
||||
@@ -756,11 +759,20 @@ func (f *fakeRemoteProvider) Resolve(context.Context, string) (*cloud.DirectLink
|
||||
return &cloud.DirectLink{URL: "http://cdn/x.mkv"}, nil
|
||||
}
|
||||
|
||||
// listedCount 返回某个目录被 List 的次数。
|
||||
func (f *fakeRemoteProvider) listedCount(dirID string) int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.listed[dirID]
|
||||
}
|
||||
|
||||
func (f *fakeRemoteProvider) List(_ context.Context, dirID string) ([]cloud.FileEntry, error) {
|
||||
f.mu.Lock()
|
||||
if f.listed == nil {
|
||||
f.listed = map[string]int{}
|
||||
}
|
||||
f.listed[dirID]++
|
||||
f.mu.Unlock()
|
||||
switch dirID {
|
||||
case "root":
|
||||
return []cloud.FileEntry{
|
||||
@@ -828,8 +840,8 @@ func TestWalkRemoteConcurrent(t *testing.T) {
|
||||
st.flushPreferredVideos()
|
||||
|
||||
for _, dir := range []string{"root", "a", "a1", "b"} {
|
||||
if provider.listed[dir] != 1 {
|
||||
t.Errorf("目录 %s 被列出 %d 次,期望 1 次", dir, provider.listed[dir])
|
||||
if count := provider.listedCount(dir); count != 1 {
|
||||
t.Errorf("目录 %s 被列出 %d 次,期望 1 次", dir, count)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -141,8 +141,13 @@ const profileQuery = () => {
|
||||
// streamURL returns a direct-play URL for <video src>. The JWT is added as
|
||||
// a query parameter because <video> elements cannot send Authorization
|
||||
// headers.
|
||||
export function streamURL(mediaId: string): string {
|
||||
return `/api/stream/${encodeURIComponent(mediaId)}?${tokenQuery()}${profileQuery()}`
|
||||
//
|
||||
// proxy=true 时由服务端把网盘/STRM 直链转发为同源数据(画质与原文件一致、
|
||||
// 不转码)。VR 全景渲染需要把视频帧读进 WebGL 纹理,跨域直链会被浏览器
|
||||
// 判定为污染资源而禁止读取,因此只有这种场景才需要开启。
|
||||
export function streamURL(mediaId: string, options: { proxy?: boolean } = {}): string {
|
||||
const proxy = options.proxy ? '&proxy=1' : ''
|
||||
return `/api/stream/${encodeURIComponent(mediaId)}?${tokenQuery()}${profileQuery()}${proxy}`
|
||||
}
|
||||
|
||||
// hlsURL returns the m3u8 playlist URL fed into hls.js.
|
||||
|
||||
@@ -71,6 +71,9 @@ export interface DanmakuConfig {
|
||||
font_size: string
|
||||
area: string
|
||||
volume: number
|
||||
playback_rate: number
|
||||
/** 当前用户是否已经看过 VR 全景播放的首次操作说明(按用户存储)。 */
|
||||
vr360_guide_seen: boolean
|
||||
/** Per-user preference: merge the same episode's multiple sources. */
|
||||
merge_sources: boolean
|
||||
}
|
||||
@@ -87,6 +90,9 @@ export interface DanmakuSettingsPatch {
|
||||
area?: number
|
||||
merge_sources?: boolean
|
||||
volume?: number
|
||||
playback_rate?: number
|
||||
/** 看过 VR 操作说明后置为 true,之后不再弹出。 */
|
||||
vr360_guide_seen?: boolean
|
||||
}
|
||||
|
||||
// danmakuAPI fetches danmaku comments for a media item. The backend resolves
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { api } from './client'
|
||||
import type { User } from '../types'
|
||||
import type { LibraryTagSet, User } from '../types'
|
||||
|
||||
export const profileAPI = {
|
||||
get: () => api.get<User>('/me').then((r) => r.data),
|
||||
@@ -21,6 +21,12 @@ export const profileAPI = {
|
||||
setPinnedLibraries: (libraryIds: string[]) =>
|
||||
api.put<{ library_ids: string[] }>('/me/pinned-libraries', { library_ids: libraryIds }).then((r) => r.data.library_ids ?? []),
|
||||
|
||||
getLibraryTags: () =>
|
||||
api.get<{ tags: LibraryTagSet[] | null }>('/me/library-tags').then((r) => r.data.tags ?? []),
|
||||
|
||||
setLibraryTags: (tags: LibraryTagSet[]) =>
|
||||
api.put<{ tags: LibraryTagSet[] | null }>('/me/library-tags', { tags }).then((r) => r.data.tags ?? []),
|
||||
|
||||
adminUpdateRole: (id: string, role: 'admin' | 'user') =>
|
||||
api.patch<User>(`/admin/users/${id}/role`, { role }).then((r) => r.data),
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ export function GlobalEvents() {
|
||||
}
|
||||
}
|
||||
if (topic === 'scrape' && p.finished) {
|
||||
if (role !== 'admin') return
|
||||
const processed = Number(p.processed ?? 0)
|
||||
const matched = Number(p.matched ?? 0)
|
||||
const failed = Number(p.failed ?? 0)
|
||||
@@ -51,6 +52,7 @@ export function GlobalEvents() {
|
||||
}
|
||||
}
|
||||
if (topic === 'subscription') {
|
||||
if (role !== 'admin') return
|
||||
const queued = (p.queued as number | undefined) ?? 0
|
||||
if (queued > 0) toast.success(`订阅「${p.name}」已加入 ${queued} 项下载`)
|
||||
}
|
||||
|
||||
@@ -87,7 +87,12 @@ export function Layout() {
|
||||
pathname={location.pathname}
|
||||
/>
|
||||
)}
|
||||
<LayoutWorkspace routeKey={location.pathname} showMobileBottomNav={showMobileBottomNav} />
|
||||
<LayoutWorkspace
|
||||
routeKey={location.pathname}
|
||||
scrollKey={`${location.pathname}${location.search}`}
|
||||
userKey={user?.id}
|
||||
showMobileBottomNav={showMobileBottomNav}
|
||||
/>
|
||||
{showMobileBottomNav && (
|
||||
<MobileBottomNav onOpenMenu={() => sidebar.setIsMobileDrawerOpen(true)} />
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import clsx from 'clsx'
|
||||
import { LayoutSidebarContent, type LayoutSidebarContentProps } from './LayoutSidebarContent'
|
||||
import { isPlayerRoute } from './layoutNavigation'
|
||||
import { RouteErrorBoundary } from './RouteErrorBoundary'
|
||||
import { useScrollMemory } from '../hooks/useScrollMemory'
|
||||
import type { useLayoutSidebar } from './useLayoutSidebar'
|
||||
|
||||
type LayoutSidebarState = ReturnType<typeof useLayoutSidebar>
|
||||
@@ -30,6 +31,9 @@ type LayoutSidebarsProps = Omit<
|
||||
|
||||
type LayoutWorkspaceProps = {
|
||||
routeKey: string
|
||||
/** 滚动记忆的 key:含 query,让同一路径下的不同视图(如剧集面板)各记一份位置。 */
|
||||
scrollKey?: string
|
||||
userKey?: string
|
||||
showMobileBottomNav?: boolean
|
||||
}
|
||||
|
||||
@@ -109,7 +113,14 @@ export function LayoutSidebars({
|
||||
)
|
||||
}
|
||||
|
||||
export function LayoutWorkspace({ routeKey, showMobileBottomNav = false }: LayoutWorkspaceProps) {
|
||||
export function LayoutWorkspace({
|
||||
routeKey,
|
||||
scrollKey,
|
||||
userKey = 'anonymous',
|
||||
showMobileBottomNav = false,
|
||||
}: LayoutWorkspaceProps) {
|
||||
useScrollMemory(scrollKey ?? routeKey, userKey)
|
||||
|
||||
const bottomPad = showMobileBottomNav
|
||||
? 'pb-[calc(3.75rem+env(safe-area-inset-bottom,0px))] lg:pb-10'
|
||||
: ''
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { ArrowDown, ArrowUp, ArrowUpDown, Check } from 'lucide-react'
|
||||
|
||||
import {
|
||||
LIBRARY_LIST_SORT_OPTIONS,
|
||||
type LibraryListSortField,
|
||||
type LibraryListSortOption,
|
||||
type LibraryListSortOrder,
|
||||
} from '../utils/libraryListSort'
|
||||
|
||||
type LibraryListSortDropdownProps = {
|
||||
value: LibraryListSortField
|
||||
order: LibraryListSortOrder
|
||||
onChange: (field: LibraryListSortField, order: LibraryListSortOrder) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function LibraryListSortDropdown({
|
||||
value,
|
||||
order,
|
||||
onChange,
|
||||
className = '',
|
||||
}: LibraryListSortDropdownProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
const currentOption = LIBRARY_LIST_SORT_OPTIONS.find((opt) => opt.id === value) ?? LIBRARY_LIST_SORT_OPTIONS[0]
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
const handleSelect = (option: LibraryListSortOption) => {
|
||||
if (value === option.id) {
|
||||
onChange(option.id, order === 'asc' ? 'desc' : 'asc')
|
||||
} else {
|
||||
onChange(option.id, option.defaultOrder)
|
||||
}
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`relative inline-block text-left ${className}`} ref={dropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen((prev) => !prev)}
|
||||
className="inline-flex h-9 items-center gap-1.5 rounded-xl border border-sand-200 bg-white/90 px-3 py-1.5 text-xs font-semibold text-ink-600 shadow-sm transition-all hover:border-brand-300 hover:bg-brand-50/50 hover:text-brand-700 sm:h-10 sm:text-sm"
|
||||
title="更改媒体库列表排序"
|
||||
>
|
||||
<ArrowUpDown size={14} className="text-sand-500" />
|
||||
<span>排序: {currentOption.label}</span>
|
||||
{order === 'asc' ? (
|
||||
<ArrowUp size={13} className="font-bold text-brand-600" />
|
||||
) : (
|
||||
<ArrowDown size={13} className="font-bold text-brand-600" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute left-0 z-50 mt-1.5 w-44 max-w-[calc(100vw-2rem)] origin-top-left rounded-2xl border border-sand-200/80 bg-white/95 p-1.5 shadow-xl backdrop-blur-md transition-all animate-in fade-in-0 zoom-in-95 sm:left-auto sm:right-0 sm:origin-top-right">
|
||||
<div className="border-b border-sand-100 px-2.5 py-1.5 text-[11px] font-bold text-sand-500">
|
||||
排序方式
|
||||
</div>
|
||||
<div className="space-y-0.5 py-1">
|
||||
{LIBRARY_LIST_SORT_OPTIONS.map((option) => {
|
||||
const isSelected = value === option.id
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
onClick={() => handleSelect(option)}
|
||||
className={`flex w-full items-center justify-between rounded-xl px-3 py-2 text-xs font-medium transition-colors ${
|
||||
isSelected
|
||||
? 'bg-brand-50 font-semibold text-brand-700'
|
||||
: 'text-ink-600 hover:bg-sand-50 hover:text-brand-600'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isSelected && <Check size={13} className="text-brand-600" />}
|
||||
<span className={isSelected ? '' : 'pl-5'}>{option.label}</span>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<div className="flex items-center text-brand-600">
|
||||
{order === 'asc' ? (
|
||||
<ArrowUp size={14} className="stroke-[2.5]" />
|
||||
) : (
|
||||
<ArrowDown size={14} className="stroke-[2.5]" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Layers, Plus, Settings2, X } from 'lucide-react'
|
||||
|
||||
import { MAX_LIBRARY_TAGS } from '../utils/libraryTags'
|
||||
import type { LibraryTagTab } from '../utils/libraryTags'
|
||||
|
||||
/**
|
||||
* 媒体库标签栏:固定「全部」+ 每个标签一个页签,选中后只展示该标签下的媒体库。
|
||||
* 只是管理员/普通用户都能用的展示层过滤,不改动媒体库本身。
|
||||
* 标签顺序由「管理标签」里的拖拽决定,这里只负责展示与选中。
|
||||
*/
|
||||
export function LibraryTagBar({
|
||||
tabs,
|
||||
selectedTagId,
|
||||
onSelect,
|
||||
onCreate,
|
||||
onManage,
|
||||
manageLabel = '管理标签',
|
||||
showCreate = true,
|
||||
busy = false,
|
||||
}: {
|
||||
tabs: LibraryTagTab[]
|
||||
selectedTagId: string
|
||||
onSelect: (tagId: string) => void
|
||||
onCreate?: (name: string) => void
|
||||
onManage?: () => void
|
||||
manageLabel?: string
|
||||
showCreate?: boolean
|
||||
busy?: boolean
|
||||
}) {
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [draft, setDraft] = useState('')
|
||||
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||
// 同一帧内 blur 与 Enter 都可能触发提交:用一个标记保证只提交一次。
|
||||
const submittedRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (creating) {
|
||||
submittedRef.current = false
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
}, [creating])
|
||||
|
||||
const hasTags = useMemo(() => tabs.some((tab) => !tab.isAll), [tabs])
|
||||
|
||||
const submit = () => {
|
||||
if (submittedRef.current) return
|
||||
submittedRef.current = true
|
||||
const name = draft.trim()
|
||||
setDraft('')
|
||||
setCreating(false)
|
||||
if (!name) return
|
||||
onCreate?.(name)
|
||||
}
|
||||
|
||||
const cancel = () => {
|
||||
submittedRef.current = true
|
||||
setDraft('')
|
||||
setCreating(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-[var(--app-border)] pb-3">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{tabs.map((tab) => {
|
||||
const active = tab.id === selectedTagId
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(tab.id)}
|
||||
aria-pressed={active}
|
||||
className={`inline-flex items-center gap-1.5 rounded-xl border px-3 py-1.5 text-xs font-bold transition-colors ${
|
||||
active
|
||||
? 'border-brand-500/60 bg-brand-500/10 text-brand-600'
|
||||
: 'border-[var(--app-border)] bg-[var(--app-panel-soft)] text-[var(--app-subtle)] hover:border-brand-500/40 hover:text-[var(--app-text)]'
|
||||
}`}
|
||||
title={tab.isAll ? '显示全部媒体库' : `只看「${tab.name}」标签下的媒体库`}
|
||||
>
|
||||
{tab.isAll && <Layers size={13} />}
|
||||
<span className="max-w-[8rem] truncate">{tab.name}</span>
|
||||
<span className={`font-mono text-[10px] ${active ? 'text-brand-500' : 'text-[var(--app-muted)]'}`}>
|
||||
{tab.count}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{(showCreate || onManage) && (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{showCreate && onCreate && (creating || !hasTags) && (
|
||||
<div className="inline-flex items-center gap-1 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-2 py-1">
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
submit()
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
setDraft('')
|
||||
setCreating(false)
|
||||
}
|
||||
}}
|
||||
onBlur={() => submit()}
|
||||
maxLength={24}
|
||||
placeholder="新标签名"
|
||||
className="h-6 w-24 bg-transparent text-xs font-semibold text-[var(--app-text)] outline-none placeholder:text-[var(--app-muted)]"
|
||||
title={`最多 24 个字,当前最多 ${MAX_LIBRARY_TAGS} 个标签`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={submit}
|
||||
disabled={busy || !draft.trim()}
|
||||
className="rounded-lg p-1 text-brand-500 transition-colors hover:bg-[var(--app-hover)] disabled:opacity-40"
|
||||
title="创建标签"
|
||||
>
|
||||
<Plus size={13} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={cancel}
|
||||
className="rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)]"
|
||||
title="取消"
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCreate && onCreate && !creating && hasTags && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreating(true)}
|
||||
disabled={busy || tabs.length - 1 >= MAX_LIBRARY_TAGS}
|
||||
className="inline-flex items-center gap-1 rounded-xl border border-dashed border-[var(--app-border)] px-2.5 py-1.5 text-xs font-bold text-[var(--app-muted)] transition-colors hover:border-brand-500/50 hover:text-brand-500 disabled:opacity-40"
|
||||
title="新建标签"
|
||||
>
|
||||
<Plus size={13} />
|
||||
<span>新建标签</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{onManage && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onManage}
|
||||
className="inline-flex items-center gap-1 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-2.5 py-1.5 text-xs font-bold text-[var(--app-subtle)] transition-colors hover:border-brand-500/40 hover:text-brand-500"
|
||||
title="重命名 / 删除标签,调整标签下的媒体库,拖拽排序"
|
||||
>
|
||||
<Settings2 size={13} />
|
||||
<span>{manageLabel}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Check, GripVertical, Loader2, Pencil, Plus, Tag, Trash2, X } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { confirmAction } from './confirmAction'
|
||||
import type { Library } from '../types'
|
||||
import { useDragReorder } from '../hooks/useDragReorder'
|
||||
import {
|
||||
MAX_LIBRARY_TAGS,
|
||||
filterLibrariesForTagging,
|
||||
normalizeTagName,
|
||||
tagNameError,
|
||||
type LibraryTag,
|
||||
} from '../utils/libraryTags'
|
||||
|
||||
export type ManageLibraryTagsDialogProps = {
|
||||
tags: LibraryTag[]
|
||||
libraries: Library[]
|
||||
saving: boolean
|
||||
onCreate: (name: string) => Promise<string>
|
||||
onRename: (from: string, to: string) => Promise<void>
|
||||
onRemove: (name: string) => Promise<void>
|
||||
/** 按给定标签名顺序重排标签(拖拽排序的落点)。 */
|
||||
onReorder: (orderedNames: string[]) => Promise<void>
|
||||
/** 单个媒体库归类;tagName 为空表示移出所有标签。 */
|
||||
onAssign: (libraryId: string, tagName: string) => Promise<void>
|
||||
/** 批量归类;tagName 为空表示批量移出所有标签。一次写入。 */
|
||||
onAssignBatch: (libraryIds: string[], tagName: string) => Promise<void>
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/** 「未分类」在筛选/批量目标里的哨兵值,与标签名不会冲突。 */
|
||||
const UNTAGGED = '__untagged__'
|
||||
|
||||
/**
|
||||
* 标签管理对话框:左侧维护标签(新建 / 重命名 / 删除),右侧把媒体库归入
|
||||
* 某个标签。一个媒体库同时只属于一个标签,未选择即为「未分类」。
|
||||
*
|
||||
* 右侧支持搜索、按当前标签筛选、多选后一次性批量归类,避免逐条点击下拉框。
|
||||
*/
|
||||
export function ManageLibraryTagsDialogView({
|
||||
tags,
|
||||
libraries,
|
||||
saving,
|
||||
onCreate,
|
||||
onRename,
|
||||
onRemove,
|
||||
onReorder,
|
||||
onAssign,
|
||||
onAssignBatch,
|
||||
onClose,
|
||||
}: ManageLibraryTagsDialogProps) {
|
||||
const [draftName, setDraftName] = useState('')
|
||||
const [renaming, setRenaming] = useState('')
|
||||
const [renameDraft, setRenameDraft] = useState('')
|
||||
const [busyLibrary, setBusyLibrary] = useState('')
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [tagFilter, setTagFilter] = useState('')
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
||||
const [batchTag, setBatchTag] = useState('')
|
||||
const [applying, setApplying] = useState(false)
|
||||
const { draggingId, dragOverId, dragProps } = useDragReorder(
|
||||
tags.map((tag) => tag.name),
|
||||
onReorder,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') onClose()
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [onClose])
|
||||
|
||||
const tagByLibrary = useMemo(() => {
|
||||
const map = new Map<string, string>()
|
||||
for (const tag of tags) {
|
||||
for (const id of tag.library_ids) {
|
||||
if (!map.has(id)) map.set(id, tag.name)
|
||||
}
|
||||
}
|
||||
return map
|
||||
}, [tags])
|
||||
|
||||
// 标签被删除/重命名后,批量目标与筛选值可能已经失效,落回默认项。
|
||||
useEffect(() => {
|
||||
if (batchTag && batchTag !== UNTAGGED && !tags.some((tag) => tag.name === batchTag)) {
|
||||
setBatchTag('')
|
||||
}
|
||||
if (tagFilter && tagFilter !== UNTAGGED && !tags.some((tag) => tag.name === tagFilter)) {
|
||||
setTagFilter('')
|
||||
}
|
||||
}, [tags, batchTag, tagFilter])
|
||||
|
||||
// 媒体库被删除后清掉已选项,避免批量应用时带着不存在的 ID。
|
||||
useEffect(() => {
|
||||
const available = new Set(libraries.map((lib) => lib.id))
|
||||
setSelectedIds((prev) => {
|
||||
const next = prev.filter((id) => available.has(id))
|
||||
return next.length === prev.length ? prev : next
|
||||
})
|
||||
}, [libraries])
|
||||
|
||||
const visibleLibraries = useMemo(() => {
|
||||
const searched = filterLibrariesForTagging(libraries, keyword)
|
||||
if (!tagFilter) return searched
|
||||
return searched.filter((lib) => {
|
||||
const current = tagByLibrary.get(lib.id) ?? ''
|
||||
return tagFilter === UNTAGGED ? current === '' : current === tagFilter
|
||||
})
|
||||
}, [libraries, keyword, tagFilter, tagByLibrary])
|
||||
|
||||
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds])
|
||||
const visibleSelectedCount = visibleLibraries.reduce(
|
||||
(sum, lib) => sum + (selectedSet.has(lib.id) ? 1 : 0),
|
||||
0,
|
||||
)
|
||||
const allVisibleSelected = visibleLibraries.length > 0 && visibleSelectedCount === visibleLibraries.length
|
||||
|
||||
const toggleLibrary = useCallback((libraryId: string) => {
|
||||
setSelectedIds((prev) =>
|
||||
prev.includes(libraryId) ? prev.filter((id) => id !== libraryId) : [...prev, libraryId],
|
||||
)
|
||||
}, [])
|
||||
|
||||
const toggleVisible = useCallback(() => {
|
||||
const visibleIds = visibleLibraries.map((lib) => lib.id)
|
||||
setSelectedIds((prev) => {
|
||||
const current = new Set(prev)
|
||||
const everySelected = visibleIds.length > 0 && visibleIds.every((id) => current.has(id))
|
||||
if (everySelected) return prev.filter((id) => !visibleIds.includes(id))
|
||||
for (const id of visibleIds) current.add(id)
|
||||
return Array.from(current)
|
||||
})
|
||||
}, [visibleLibraries])
|
||||
|
||||
const handleCreate = async () => {
|
||||
const error = tagNameError(draftName, tags)
|
||||
if (error) {
|
||||
toast.error(error)
|
||||
return
|
||||
}
|
||||
if (tags.length >= MAX_LIBRARY_TAGS) {
|
||||
toast.error(`最多 ${MAX_LIBRARY_TAGS} 个标签`)
|
||||
return
|
||||
}
|
||||
const created = await onCreate(normalizeTagName(draftName))
|
||||
if (created) {
|
||||
setDraftName('')
|
||||
toast.success(`标签「${created}」已创建`)
|
||||
} else {
|
||||
toast.error('标签创建失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleRename = async (from: string) => {
|
||||
const error = tagNameError(renameDraft, tags, from)
|
||||
if (error) {
|
||||
toast.error(error)
|
||||
return
|
||||
}
|
||||
await onRename(from, renameDraft)
|
||||
setRenaming('')
|
||||
setRenameDraft('')
|
||||
}
|
||||
|
||||
const handleRemove = async (tag: LibraryTag) => {
|
||||
const confirmed = await confirmAction({
|
||||
title: '删除标签',
|
||||
message: `确定删除标签「${tag.name}」?\n媒体库本身不会被删除,只会回到「未分类」。`,
|
||||
confirmText: '删除',
|
||||
})
|
||||
if (!confirmed) return
|
||||
await onRemove(tag.name)
|
||||
}
|
||||
|
||||
const handleAssign = async (library: Library, tagName: string) => {
|
||||
setBusyLibrary(library.id)
|
||||
try {
|
||||
await onAssign(library.id, tagName)
|
||||
} finally {
|
||||
setBusyLibrary('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleApplyBatch = async () => {
|
||||
if (selectedIds.length === 0 || applying) return
|
||||
setApplying(true)
|
||||
try {
|
||||
await onAssignBatch(selectedIds, batchTag)
|
||||
const count = selectedIds.length
|
||||
toast.success(
|
||||
batchTag ? `已将 ${count} 个媒体库归入「${batchTag}」` : `已将 ${count} 个媒体库移出标签`,
|
||||
)
|
||||
setSelectedIds([])
|
||||
} finally {
|
||||
setApplying(false)
|
||||
}
|
||||
}
|
||||
|
||||
const busy = saving || applying
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/35 p-2 backdrop-blur-sm sm:p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className="flex h-[calc(100dvh-1rem)] w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-white/70 bg-[var(--app-panel)] shadow-2xl sm:h-[82vh] sm:rounded-3xl"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-[var(--app-border)] px-4 py-3 sm:px-6 sm:py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tag size={18} className="text-brand-500" />
|
||||
<h3 className="font-display text-base font-bold text-[var(--app-text)] sm:text-lg">管理标签</h3>
|
||||
{busy && <Loader2 size={14} className="animate-spin text-[var(--app-muted)]" />}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-xl p-1.5 text-[var(--app-muted)] transition hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]"
|
||||
title="关闭"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid flex-1 grid-cols-1 gap-4 overflow-hidden p-3 sm:p-6 md:grid-cols-[minmax(0,1fr)_minmax(0,1.5fr)]">
|
||||
<section className="flex min-h-0 flex-col gap-3">
|
||||
<h4 className="text-xs font-bold uppercase tracking-widest text-[var(--app-muted)]">标签</h4>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
value={draftName}
|
||||
onChange={(event) => setDraftName(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
void handleCreate()
|
||||
}
|
||||
}}
|
||||
maxLength={24}
|
||||
placeholder="新建标签名"
|
||||
className="input-base !py-2 text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleCreate()}
|
||||
disabled={busy || !draftName.trim()}
|
||||
className="btn-outline shrink-0 !px-3 !py-2 text-xs"
|
||||
title="创建标签"
|
||||
>
|
||||
<Plus size={14} />
|
||||
新建
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-1.5 overflow-y-auto pr-1">
|
||||
{tags.length === 0 ? (
|
||||
<p className="rounded-xl border border-dashed border-[var(--app-border)] px-3 py-6 text-center text-xs text-[var(--app-muted)]">
|
||||
还没有标签。先创建一个,再把媒体库归入其中。
|
||||
</p>
|
||||
) : (
|
||||
tags.map((tag) => {
|
||||
const drag = dragProps(tag.name)
|
||||
const isDragOver = dragOverId === tag.name && draggingId !== tag.name
|
||||
return (
|
||||
<div
|
||||
key={tag.name}
|
||||
onDragOver={drag.onDragOver}
|
||||
onDragLeave={drag.onDragLeave}
|
||||
onDrop={drag.onDrop}
|
||||
className={`flex items-center gap-2 rounded-xl border px-2.5 py-2 transition-colors ${
|
||||
isDragOver
|
||||
? 'border-brand-500/60 bg-brand-500/5'
|
||||
: 'border-[var(--app-border)] bg-[var(--app-panel-soft)]'
|
||||
}`}
|
||||
>
|
||||
{renaming === tag.name ? (
|
||||
<>
|
||||
<input
|
||||
autoFocus
|
||||
value={renameDraft}
|
||||
onChange={(event) => setRenameDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
void handleRename(tag.name)
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
setRenaming('')
|
||||
setRenameDraft('')
|
||||
}
|
||||
}}
|
||||
maxLength={24}
|
||||
className="min-w-0 flex-1 rounded-lg border border-[var(--app-border)] bg-[var(--app-panel)] px-2 py-1 text-xs font-semibold text-[var(--app-text)] outline-none focus:border-brand-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRename(tag.name)}
|
||||
className="rounded-lg p-1 text-brand-500 transition-colors hover:bg-[var(--app-hover)]"
|
||||
title="保存名称"
|
||||
>
|
||||
<Check size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setRenaming('')
|
||||
setRenameDraft('')
|
||||
}}
|
||||
className="rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)]"
|
||||
title="取消"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
draggable={drag.draggable}
|
||||
onDragStart={drag.onDragStart}
|
||||
onDragEnd={drag.onDragEnd}
|
||||
className={`-ml-1 shrink-0 cursor-grab rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)] ${
|
||||
draggingId === tag.name ? 'opacity-40' : ''
|
||||
}`}
|
||||
title="拖拽调整标签顺序"
|
||||
aria-label="拖拽调整标签顺序"
|
||||
>
|
||||
<GripVertical size={13} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTagFilter(tag.name)}
|
||||
className="min-w-0 flex-1 truncate text-left text-sm font-bold text-[var(--app-text)] transition-colors hover:text-brand-500"
|
||||
title={`只看「${tag.name}」标签下的媒体库`}
|
||||
>
|
||||
{tag.name}
|
||||
</button>
|
||||
<span className="shrink-0 font-mono text-[10px] text-[var(--app-muted)]">
|
||||
{tag.library_ids.length}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setRenaming(tag.name)
|
||||
setRenameDraft(tag.name)
|
||||
}}
|
||||
className="rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)] hover:text-brand-500"
|
||||
title="重命名"
|
||||
>
|
||||
<Pencil size={13} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRemove(tag)}
|
||||
className="rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)] hover:text-red-500"
|
||||
title="删除标签"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex min-h-0 flex-col gap-3 border-t border-[var(--app-border)] pt-4 md:border-l md:border-t-0 md:pl-4 md:pt-0">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h4 className="text-xs font-bold uppercase tracking-widest text-[var(--app-muted)]">
|
||||
媒体库归类({libraries.length})
|
||||
</h4>
|
||||
<span className="text-xs text-[var(--app-muted)]">勾选多个后可一次性设置标签</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<input
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="搜索名称 / 路径 / 类型"
|
||||
className="input-base h-9 min-w-[10rem] flex-1 !py-0 text-xs"
|
||||
/>
|
||||
<select
|
||||
value={tagFilter}
|
||||
onChange={(event) => setTagFilter(event.target.value)}
|
||||
className="input-base h-9 w-32 shrink-0 !py-0 text-xs"
|
||||
title="筛选媒体库"
|
||||
>
|
||||
<option value="">全部标签</option>
|
||||
<option value={UNTAGGED}>未分类</option>
|
||||
{tags.map((tag) => (
|
||||
<option key={tag.name} value={tag.name}>
|
||||
{tag.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-2.5 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleVisible}
|
||||
disabled={visibleLibraries.length === 0}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-[var(--app-border)] bg-[var(--app-panel)] px-2.5 py-1 text-xs font-bold text-[var(--app-subtle)] transition-colors hover:border-brand-500/40 hover:text-brand-500 disabled:opacity-40"
|
||||
title="全选 / 取消全选当前列表中的媒体库"
|
||||
>
|
||||
<Check size={13} />
|
||||
{allVisibleSelected ? '取消全选' : '全选'}
|
||||
</button>
|
||||
<span className="text-xs font-semibold text-[var(--app-subtle)]">
|
||||
已选 {selectedIds.length} 个
|
||||
</span>
|
||||
<select
|
||||
value={batchTag}
|
||||
onChange={(event) => setBatchTag(event.target.value)}
|
||||
className="input-base ml-auto h-8 w-36 shrink-0 !py-0 text-xs"
|
||||
title="选择要批量设置的标签"
|
||||
>
|
||||
<option value="">未分类(移出标签)</option>
|
||||
{tags.map((tag) => (
|
||||
<option key={tag.name} value={tag.name}>
|
||||
{tag.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleApplyBatch()}
|
||||
disabled={busy || selectedIds.length === 0}
|
||||
className="btn-outline shrink-0 !px-3 !py-1.5 text-xs"
|
||||
title="把选中媒体库的标签一次性设为左侧所选值"
|
||||
>
|
||||
{applying && <Loader2 size={13} className="animate-spin" />}
|
||||
应用到已选
|
||||
</button>
|
||||
{selectedIds.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedIds([])}
|
||||
disabled={applying}
|
||||
className="rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)]"
|
||||
title="清空选择"
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-1.5 overflow-y-auto pr-1">
|
||||
{visibleLibraries.length === 0 ? (
|
||||
<p className="rounded-xl border border-dashed border-[var(--app-border)] px-3 py-6 text-center text-xs text-[var(--app-muted)]">
|
||||
{libraries.length === 0 ? '暂无可归类的媒体库。' : '没有符合当前筛选条件的媒体库。'}
|
||||
</p>
|
||||
) : (
|
||||
visibleLibraries.map((library) => {
|
||||
const current = tagByLibrary.get(library.id) ?? ''
|
||||
const checked = selectedSet.has(library.id)
|
||||
return (
|
||||
<div
|
||||
key={library.id}
|
||||
className={`flex items-center gap-2 rounded-xl border px-2.5 py-2 transition-colors ${
|
||||
checked
|
||||
? 'border-brand-500/50 bg-brand-500/5'
|
||||
: 'border-[var(--app-border)] bg-[var(--app-panel-soft)]'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleLibrary(library.id)}
|
||||
disabled={applying}
|
||||
className="h-4 w-4 shrink-0 accent-brand-400"
|
||||
title="勾选后可批量设置标签"
|
||||
/>
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate text-xs font-semibold text-[var(--app-text)]"
|
||||
title={library.path || library.name}
|
||||
>
|
||||
{library.name}
|
||||
</span>
|
||||
{busyLibrary === library.id && (
|
||||
<Loader2 size={13} className="shrink-0 animate-spin text-[var(--app-muted)]" />
|
||||
)}
|
||||
<select
|
||||
value={current}
|
||||
disabled={busy || busyLibrary === library.id}
|
||||
onChange={(event) => void handleAssign(library, event.target.value)}
|
||||
className="input-base h-8 w-32 shrink-0 !py-0 text-xs"
|
||||
title="选择该媒体库所属标签"
|
||||
>
|
||||
<option value="">未分类</option>
|
||||
{tags.map((tag) => (
|
||||
<option key={tag.name} value={tag.name}>
|
||||
{tag.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 border-t border-[var(--app-border)] px-4 py-3 sm:px-6">
|
||||
<span className="text-xs text-[var(--app-muted)]">标签按用户保存;一个媒体库同时只属于一个标签。</span>
|
||||
<button type="button" onClick={onClose} className="btn-outline !px-4 !py-2 text-sm">
|
||||
完成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,9 @@ import { Film, Play, Layers, Star } from 'lucide-react'
|
||||
import { imageURL } from '../api/client'
|
||||
import type { Media } from '../types'
|
||||
|
||||
const ACTION_OVERLAY_CLASS =
|
||||
'absolute right-2 top-2 z-20 flex flex-wrap justify-end gap-1 opacity-100 transition-opacity sm:pointer-events-none sm:opacity-0 sm:group-hover:pointer-events-auto sm:group-hover:opacity-100 sm:focus-within:pointer-events-auto sm:focus-within:opacity-100'
|
||||
|
||||
// memo:父级状态变化(如轮播切图、其它卡片操作)不再级联重渲染所有卡片。
|
||||
// 注意 actions/renderActions 必须引用稳定(用 renderActions 传函数)memo 才生效。
|
||||
export const MediaCard = memo(function MediaCard({
|
||||
@@ -79,25 +82,10 @@ export const MediaCard = memo(function MediaCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Episode count badge */}
|
||||
{count !== undefined && count > 1 && (
|
||||
<span className="absolute right-3 top-3 inline-flex items-center gap-1 rounded-xl border border-white/15 bg-[#111827]/90 px-2 py-1 text-[10px] font-bold text-white shadow-sm">
|
||||
<Layers size={10} className="text-[#c9954a]" />
|
||||
<span>{count} 集</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{count === undefined && versionCount > 1 && (
|
||||
<span className="absolute right-3 top-3 inline-flex items-center gap-1 rounded-xl border border-white/15 bg-[#111827]/90 px-2 py-1 text-[10px] font-bold text-white shadow-sm">
|
||||
<Layers size={10} className="text-[#c9954a]" />
|
||||
<span>{versionCount} 版本</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Rating Badge */}
|
||||
{displayRating > 0 && (
|
||||
<span className="absolute left-3 top-3 inline-flex items-center gap-0.5 rounded-xl border border-white/15 bg-[#111827]/90 px-2 py-1 text-[10px] font-bold text-[#c9954a] shadow-sm">
|
||||
<Star size={10} fill="currentColor" />
|
||||
<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">
|
||||
<Star size={10} fill="currentColor" className="shrink-0" />
|
||||
<span>{displayRating.toFixed(1)}</span>
|
||||
</span>
|
||||
)}
|
||||
@@ -141,13 +129,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>
|
||||
@@ -160,7 +158,7 @@ export const MediaCard = memo(function MediaCard({
|
||||
{card}
|
||||
</button>
|
||||
{actionContent && (
|
||||
<div className="absolute right-2 top-2 z-20 flex flex-wrap justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
|
||||
<div className={ACTION_OVERLAY_CLASS}>
|
||||
{actionContent}
|
||||
</div>
|
||||
)}
|
||||
@@ -174,7 +172,7 @@ export const MediaCard = memo(function MediaCard({
|
||||
<Link to={href} className="block">
|
||||
{card}
|
||||
</Link>
|
||||
<div className="absolute right-2 top-2 z-20 flex flex-wrap justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
|
||||
<div className={ACTION_OVERLAY_CLASS}>
|
||||
{actionContent}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,26 +6,23 @@ import { isStrmMedia, mediaVersionLabel, mediaVersionsOf } from '../utils/mediaV
|
||||
|
||||
type MediaVersionSwitcherProps = {
|
||||
media: Media
|
||||
/** 详情页:用 Link 跳转播放;播放页:回调切换 */
|
||||
mode?: 'detail' | 'player'
|
||||
onSelect?: (version: Media) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function MediaVersionSwitcher({
|
||||
media,
|
||||
mode = 'detail',
|
||||
onSelect,
|
||||
className = '',
|
||||
}: MediaVersionSwitcherProps) {
|
||||
/**
|
||||
* 详情页的版本切换。
|
||||
*
|
||||
* 播放器内不再单独浮出一块版本条:多版本切换统一收在「选集」面板里
|
||||
* (见 PlayerPlaylistPanel),这里只负责详情页的同片多版本跳转。
|
||||
*/
|
||||
export function MediaVersionSwitcher({ media, className = '' }: MediaVersionSwitcherProps) {
|
||||
const versions = mediaVersionsOf(media)
|
||||
if (versions.length <= 1) return null
|
||||
const dark = mode === 'player'
|
||||
|
||||
return (
|
||||
<div className={`space-y-2 ${className}`.trim()}>
|
||||
<div className={`flex items-center gap-2 text-sm font-semibold ${dark ? 'text-white/90' : 'text-ink-600'}`}>
|
||||
<Layers size={14} className={dark ? 'text-white/60' : 'text-sand-500'} />
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-ink-600">
|
||||
<Layers size={14} className="text-sand-500" />
|
||||
<span>版本({versions.length})</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -33,42 +30,10 @@ export function MediaVersionSwitcher({
|
||||
const active = version.id === media.id
|
||||
const isStrm = isStrmMedia(version)
|
||||
const label = mediaVersionLabel(version)
|
||||
const btnClass = dark
|
||||
? active
|
||||
? 'border-white/50 bg-white/20 text-white shadow-sm ring-1 ring-white/20'
|
||||
: 'border-white/15 bg-white/5 text-white/80 hover:bg-white/15 hover:text-white'
|
||||
: active
|
||||
? 'border-brand-500/40 bg-brand-50 text-[#b07d35] font-bold shadow-sm'
|
||||
: 'border-gray-200 bg-white text-ink-100 hover:border-brand-500/30 hover:bg-brand-50/40'
|
||||
const btnClass = active
|
||||
? 'border-brand-500/40 bg-brand-50 text-[#b07d35] font-bold shadow-sm'
|
||||
: 'border-gray-200 bg-white text-ink-100 hover:border-brand-500/30 hover:bg-brand-50/40'
|
||||
|
||||
const content = (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{isStrm && (
|
||||
<Cloud
|
||||
size={12}
|
||||
className={dark ? (active ? 'text-brand-300' : 'text-white/60') : (active ? 'text-brand-500' : 'text-sand-400')}
|
||||
aria-label="云端 / STRM"
|
||||
/>
|
||||
)}
|
||||
<span>{label}</span>
|
||||
{!dark && active ? <span className="opacity-75">· 当前</span> : null}
|
||||
</span>
|
||||
)
|
||||
|
||||
if (mode === 'player') {
|
||||
return (
|
||||
<button
|
||||
key={version.id}
|
||||
type="button"
|
||||
disabled={active}
|
||||
onClick={() => onSelect?.(version)}
|
||||
className={`rounded-xl border px-3 py-1.5 text-xs font-semibold transition disabled:cursor-default ${btnClass}`}
|
||||
title={version.path || version.strm_url}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Link
|
||||
key={version.id}
|
||||
@@ -77,7 +42,17 @@ export function MediaVersionSwitcher({
|
||||
className={`rounded-xl border px-3 py-1.5 text-xs font-semibold transition ${btnClass}`}
|
||||
title={version.path || version.strm_url}
|
||||
>
|
||||
{content}
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{isStrm && (
|
||||
<Cloud
|
||||
size={12}
|
||||
className={active ? 'text-brand-500' : 'text-sand-400'}
|
||||
aria-label="云端 / STRM"
|
||||
/>
|
||||
)}
|
||||
<span>{label}</span>
|
||||
{active ? <span className="opacity-75">· 当前</span> : null}
|
||||
</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
|
||||
+1058
-395
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
||||
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Film, Hash, KeyRound, Loader2, MessageSquareText, RefreshCw, Search, Server, Settings2, Sparkles, Tag, X } from 'lucide-react'
|
||||
|
||||
import type { DanmakuAnime, DanmakuEpisode, DanmakuLoadedInfo } from '../api/danmaku'
|
||||
import { PLAYER_DRAWER, PLAYER_ICON_BUTTON, PLAYER_PANEL_HEADER, PLAYER_SHEET, PLAYER_SHEET_BODY, PLAYER_SHEET_HEADER } from './playerTheme'
|
||||
|
||||
// PlayerDanmakuPanel — the on-player danmaku control panel. It displays
|
||||
// the matched danmaku details (anime title, episode title, comment count,
|
||||
@@ -55,6 +56,11 @@ type PlayerDanmakuPanelProps = {
|
||||
danmakuInfo?: DanmakuLoadedInfo | null
|
||||
onSelectEpisode: (episodeId: number, animeTitle: string, episodeTitle: string) => void
|
||||
onResetAuto: () => void
|
||||
/**
|
||||
* 竖屏剧场模式:弹幕面板改为视频区底部的动作面板,而不是右侧抽屉,
|
||||
* 窄屏下搜索框与滑杆不再被压扁。
|
||||
*/
|
||||
theater?: boolean
|
||||
}
|
||||
|
||||
export function PlayerDanmakuPanel({
|
||||
@@ -89,6 +95,7 @@ export function PlayerDanmakuPanel({
|
||||
danmakuInfo,
|
||||
onSelectEpisode,
|
||||
onResetAuto,
|
||||
theater = false,
|
||||
}: PlayerDanmakuPanelProps) {
|
||||
const [draft, setDraft] = useState(search)
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||
@@ -194,27 +201,36 @@ export function PlayerDanmakuPanel({
|
||||
|
||||
return (
|
||||
// 面板悬浮于视频上方:阻止点击冒泡,避免触发视频区域的播放/暂停切换。
|
||||
// 排布和选集抽屉完全一致(整条贴住右边缘、标题栏常驻),两个面板互斥打开时
|
||||
// 位置不会跳,用户也不用重新找入口。
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="absolute inset-x-3 top-12 bottom-3 z-30 w-auto overflow-y-auto overscroll-contain rounded-2xl border border-white/15 bg-black/85 p-4 text-white shadow-2xl backdrop-blur-md sm:inset-x-auto sm:bottom-auto sm:right-4 sm:top-16 sm:w-80 sm:max-h-[calc(100%-5rem)]"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onPointerUp={(e) => e.stopPropagation()}
|
||||
className={
|
||||
theater ? PLAYER_SHEET : `absolute inset-y-0 right-0 z-30 ${PLAYER_DRAWER}`
|
||||
}
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<MessageSquareText size={16} className="text-rose-400" /> 弹幕设置
|
||||
{settingsSaving && <Loader2 size={12} className="animate-spin text-rose-300" />}
|
||||
<div className={theater ? PLAYER_SHEET_HEADER : PLAYER_PANEL_HEADER}>
|
||||
<div className="flex min-w-0 items-center gap-2 text-sm font-semibold">
|
||||
<MessageSquareText size={16} className="shrink-0 text-rose-400" />
|
||||
<span className="truncate">弹幕设置</span>
|
||||
{settingsSaving && <Loader2 size={12} className="shrink-0 animate-spin text-rose-300" />}
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-full p-1 text-white/60 transition hover:bg-white/10 hover:text-white"
|
||||
title="关闭"
|
||||
className={`${PLAYER_ICON_BUTTON} ${theater ? 'h-10 w-10' : ''}`}
|
||||
title="关闭 (Esc)"
|
||||
>
|
||||
<X size={16} />
|
||||
<X size={theater ? 20 : 16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 是否加载弹幕 */}
|
||||
<div className={theater ? PLAYER_SHEET_BODY : 'min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-3.5'}>
|
||||
{/* 弹幕总开关。从操作栏的「弹」按钮直接进来就到了这里,所以这一项就是
|
||||
用户找的「开关弹幕」;文案跟旧的按钮提示保持一致,避免换个说法让人找不到。 */}
|
||||
<label className="mb-3 flex cursor-pointer items-center justify-between rounded-lg bg-white/5 px-2.5 py-2 text-sm transition hover:bg-white/10">
|
||||
<span className="text-white/85">加载弹幕</span>
|
||||
<span className="text-white/85">显示弹幕</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
@@ -573,6 +589,7 @@ export function PlayerDanmakuPanel({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { ChevronDown, Layers, ListVideo } from 'lucide-react'
|
||||
|
||||
import type { Media } from '../types'
|
||||
import { mediaVersionLabel, mediaVersionMatches } from '../utils/mediaVersion'
|
||||
|
||||
// PlayerMobileTheaterInfo — 竖屏剧场模式下视频下方的可滚动内容区。
|
||||
//
|
||||
// 竖屏手机不再用全屏居中黑边布局:视频贴顶按 16:9 自适应高度,
|
||||
// 下面这块区域接管标题、选集、版本切换和简介,信息密度与
|
||||
// 主流手机视频应用保持一致。桌面端与横屏手机不渲染它。
|
||||
//
|
||||
// 选集刻意放在这里而不是弹层里:从操作栏点「选集」只做「展开 + 滚到这里」,
|
||||
// 播放画面始终可见,不会像浮层那样盖住正在看的内容。
|
||||
|
||||
type PlayerMobileTheaterInfoProps = {
|
||||
media: Media | null
|
||||
title: string
|
||||
subtitle?: string
|
||||
playbackModeLabel?: string
|
||||
qualityLabel?: string
|
||||
episodes: Media[]
|
||||
currentMediaId: string
|
||||
currentEpisodeIndex: number
|
||||
currentVersions?: Media[]
|
||||
onSelectEpisode: (media: Media) => void
|
||||
onSelectVersion?: (media: Media) => void
|
||||
/** 选集列表是否展开(展开后由 playlistPanel 接管,提供季/搜索/版本)。 */
|
||||
playlistOpen: boolean
|
||||
onTogglePlaylist: () => void
|
||||
/** 展开状态下的选集内容(复用播放器的选集面板,内嵌渲染)。 */
|
||||
playlistPanel?: ReactNode
|
||||
/**
|
||||
* 每次从操作栏点「选集」都会 +1:即使列表已经展开,也要把这块滚回视野内。
|
||||
*/
|
||||
revealToken?: number
|
||||
}
|
||||
|
||||
function episodeShortLabel(ep: Media): string {
|
||||
if (ep.episode_num > 0) return `${ep.episode_num}`
|
||||
const title = ep.episode_title?.trim() || ep.title?.trim() || ''
|
||||
return title ? title.slice(0, 4) : '·'
|
||||
}
|
||||
|
||||
function episodeFullLabel(ep: Media): string {
|
||||
if (ep.episode_num > 0) {
|
||||
const title = ep.episode_title?.trim()
|
||||
return title && title !== ep.title?.trim() ? `第 ${ep.episode_num} 集 · ${title}` : `第 ${ep.episode_num} 集`
|
||||
}
|
||||
return ep.episode_title?.trim() || ep.title?.trim() || '未命名'
|
||||
}
|
||||
|
||||
export function PlayerMobileTheaterInfo({
|
||||
media,
|
||||
title,
|
||||
subtitle,
|
||||
playbackModeLabel,
|
||||
qualityLabel,
|
||||
episodes,
|
||||
currentMediaId,
|
||||
currentEpisodeIndex,
|
||||
currentVersions = [],
|
||||
onSelectEpisode,
|
||||
onSelectVersion,
|
||||
playlistOpen,
|
||||
onTogglePlaylist,
|
||||
playlistPanel,
|
||||
revealToken = 0,
|
||||
}: PlayerMobileTheaterInfoProps) {
|
||||
const episodeSectionRef = useRef<HTMLElement | null>(null)
|
||||
const hasEpisodeList = episodes.length > 1
|
||||
const versionsToSwitch = currentVersions.length > 1 ? currentVersions : []
|
||||
const overview = media?.overview?.trim() || ''
|
||||
|
||||
// 从操作栏点「选集」:把选集区滚回视野内,用户不用自己往上翻。
|
||||
useEffect(() => {
|
||||
if (!revealToken) return
|
||||
episodeSectionRef.current?.scrollIntoView({ block: 'start', behavior: 'smooth' })
|
||||
}, [revealToken])
|
||||
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain bg-black px-4 pb-8 pt-3 text-white">
|
||||
<h1 className="text-[15px] font-semibold leading-snug text-white/95">{title || '加载中…'}</h1>
|
||||
{subtitle ? <p className="mt-1 text-xs text-white/55">{subtitle}</p> : null}
|
||||
{(playbackModeLabel || qualityLabel) && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
{qualityLabel ? (
|
||||
<span className="rounded-md bg-white/10 px-2 py-0.5 text-[11px] font-medium text-white/80">
|
||||
{qualityLabel}
|
||||
</span>
|
||||
) : null}
|
||||
{playbackModeLabel ? (
|
||||
<span className="rounded-md bg-white/10 px-2 py-0.5 text-[11px] text-white/60">
|
||||
{playbackModeLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasEpisodeList ? (
|
||||
<section ref={episodeSectionRef} className="mt-4 scroll-mt-2">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h2 className="flex items-center gap-1.5 text-[13px] font-semibold text-white/90">
|
||||
<ListVideo size={14} className="text-rose-400" />
|
||||
选集
|
||||
<span className="font-mono text-[11px] font-normal text-white/45">
|
||||
{currentEpisodeIndex >= 0
|
||||
? `${currentEpisodeIndex + 1}/${episodes.length}`
|
||||
: `${episodes.length} 集`}
|
||||
</span>
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTogglePlaylist}
|
||||
className="flex min-h-8 items-center gap-1 rounded-md px-2 text-[11px] text-white/60 transition hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
{playlistOpen ? '收起' : '全部'}
|
||||
<ChevronDown
|
||||
size={13}
|
||||
className={`transition-transform ${playlistOpen ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{playlistOpen && playlistPanel ? (
|
||||
playlistPanel
|
||||
) : (
|
||||
<div className="scrollbar-hide -mx-4 flex gap-1.5 overflow-x-auto px-4 pb-1">
|
||||
{episodes.map((ep) => {
|
||||
const active = mediaVersionMatches(ep, currentMediaId)
|
||||
return (
|
||||
<button
|
||||
key={ep.id}
|
||||
type="button"
|
||||
onClick={() => onSelectEpisode(ep)}
|
||||
title={episodeFullLabel(ep)}
|
||||
className={`flex h-10 min-w-12 shrink-0 items-center justify-center rounded-lg px-2 text-xs font-medium tabular-nums transition ${
|
||||
active
|
||||
? 'bg-rose-500 text-white'
|
||||
: 'bg-white/[0.07] text-white/80 active:bg-white/15'
|
||||
}`}
|
||||
>
|
||||
{episodeShortLabel(ep)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{/* 多版本切换:展开选集后由选集面板顶部的版本区接管,这里不重复一份 */}
|
||||
{versionsToSwitch.length > 0 && !playlistOpen ? (
|
||||
<section className="mt-4">
|
||||
<h2 className="mb-2 flex items-center gap-1.5 text-[13px] font-semibold text-white/90">
|
||||
<Layers size={13} className="text-rose-400" />
|
||||
版本
|
||||
<span className="font-mono text-[11px] font-normal text-white/45">
|
||||
{versionsToSwitch.length} 个
|
||||
</span>
|
||||
</h2>
|
||||
<div className="space-y-1.5">
|
||||
{versionsToSwitch.map((version) => {
|
||||
const active = mediaVersionMatches(version, currentMediaId)
|
||||
return (
|
||||
<button
|
||||
key={version.id}
|
||||
type="button"
|
||||
disabled={active}
|
||||
onClick={() => onSelectVersion?.(version)}
|
||||
className={`flex w-full items-center gap-2 rounded-lg border px-2.5 py-2 text-left transition disabled:cursor-default ${
|
||||
active
|
||||
? 'border-rose-500/50 bg-rose-500/15 text-white'
|
||||
: 'border-white/5 bg-white/5 text-white/85 active:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-medium">
|
||||
{mediaVersionLabel(version)}
|
||||
</span>
|
||||
{active ? <span className="shrink-0 text-[10px] text-rose-300">播放中</span> : null}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{overview ? (
|
||||
<section className="mt-4">
|
||||
<h2 className="mb-1.5 text-[13px] font-semibold text-white/90">简介</h2>
|
||||
<p className="text-xs leading-relaxed text-white/60">{overview}</p>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<p className="mt-5 text-center text-[10px] text-white/30">将手机横放可获得全屏沉浸式播放体验</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,16 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Check, Film, ListVideo, Play, Search, X } from 'lucide-react'
|
||||
import { Check, Cloud, Film, Layers, Play, Search, X } from 'lucide-react'
|
||||
|
||||
import { imageURL } from '../api/client'
|
||||
import type { Media } from '../types'
|
||||
import { seasonLabel, seasonSortOrder, seriesTitleFromPath } from '../utils/groupSeries'
|
||||
import {
|
||||
isStrmMedia,
|
||||
mediaVersionFileName,
|
||||
mediaVersionLabel,
|
||||
mediaVersionMatches,
|
||||
mediaVersionSourceLabel,
|
||||
} from '../utils/mediaVersion'
|
||||
import { PLAYER_DRAWER, PLAYER_ICON_BUTTON, PLAYER_PANEL_HEADER } from './playerTheme'
|
||||
|
||||
export type SeasonGroup = {
|
||||
season: number
|
||||
@@ -14,20 +21,46 @@ type PlayerPlaylistPanelProps = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
currentMediaId: string
|
||||
/** 当前正在播放媒体的全部版本(含自身)。多版本时面板顶部展示版本切换。 */
|
||||
currentVersions?: Media[]
|
||||
episodes: Media[]
|
||||
onSelectEpisode: (media: Media) => void
|
||||
/** 切换到同一集/同一条目的另一个版本。 */
|
||||
onSelectVersion?: (media: Media) => void
|
||||
/**
|
||||
* 竖屏剧场模式:不再以浮层形式盖住播放画面,而是内嵌在视频下方的
|
||||
* 内容区里(外层负责标题与「收起」按钮),选集不再遮挡播放器。
|
||||
*/
|
||||
inline?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 选集面板 —— 右侧整条抽屉,内容参考 B 站的番剧选集。
|
||||
*
|
||||
* 以前这里是「悬浮卡片 + 每集一行缩略图」,两三百集的库翻起来很累,卡片本身
|
||||
* 也和画面上别的浮层长得不一样。现在改成:整条贴住播放区右边缘的抽屉,剧集用
|
||||
* 编号宫格排(一屏能看到几十集),标题/时长放在悬停提示里;当前播放的那一格用
|
||||
* 强调色填充。
|
||||
*
|
||||
* 多版本(同一部片的 4K / 1080p / 云端直链等)依旧不丢:宫格右上角的小角标表示
|
||||
* 这一集有多个版本,点角标会在宫格上方展开版本列表;当前正在播放的条目的版本
|
||||
* 则常驻在抽屉顶部,随时可换。单条媒体(电影)没有剧集列表时,抽屉本身就是
|
||||
* 版本选择器。
|
||||
*/
|
||||
export function PlayerPlaylistPanel({
|
||||
open,
|
||||
onClose,
|
||||
currentMediaId,
|
||||
currentVersions = [],
|
||||
episodes,
|
||||
onSelectEpisode,
|
||||
onSelectVersion,
|
||||
inline = false,
|
||||
}: PlayerPlaylistPanelProps) {
|
||||
const [filterText, setFilterText] = useState('')
|
||||
const activeItemRef = useRef<HTMLDivElement | null>(null)
|
||||
const listContainerRef = useRef<HTMLDivElement | null>(null)
|
||||
/** 正在挑选版本的剧集 id;null = 宫格直接播放。 */
|
||||
const [versionsForEpisodeId, setVersionsForEpisodeId] = useState<string | null>(null)
|
||||
const activeItemRef = useRef<HTMLButtonElement | null>(null)
|
||||
|
||||
// 按季分组
|
||||
const seasonGroups = useMemo<SeasonGroup[]>(() => {
|
||||
@@ -46,9 +79,9 @@ export function PlayerPlaylistPanel({
|
||||
.map(([season, list]) => ({ season, episodes: list }))
|
||||
}, [episodes])
|
||||
|
||||
// 当前播放所在季
|
||||
// 当前播放所在季(播放中的可能是某个剧集行的非首个版本,需按版本组比对)
|
||||
const currentSeason = useMemo(() => {
|
||||
const found = episodes.find((e) => e.id === currentMediaId)
|
||||
const found = episodes.find((e) => mediaVersionMatches(e, currentMediaId))
|
||||
if (!found) return seasonGroups[0]?.season ?? 1
|
||||
return found.episode_num > 0 ? (found.season_num ?? 0) : (found.season_num || 1)
|
||||
}, [episodes, currentMediaId, seasonGroups])
|
||||
@@ -62,15 +95,19 @@ export function PlayerPlaylistPanel({
|
||||
}
|
||||
}, [open, currentSeason])
|
||||
|
||||
// 换集/换季时收起版本列表:那张列表是针对上一集的,留着会让人误点。
|
||||
useEffect(() => {
|
||||
setVersionsForEpisodeId(null)
|
||||
}, [open, selectedSeason, currentMediaId])
|
||||
|
||||
// 当面板打开时,自动平滑滚动到当前播放集的位置
|
||||
useEffect(() => {
|
||||
if (open && activeItemRef.current) {
|
||||
const timer = setTimeout(() => {
|
||||
activeItemRef.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
}, 100)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [open, selectedSeason, currentMediaId])
|
||||
if (!open || versionsForEpisodeId) return
|
||||
const timer = setTimeout(() => {
|
||||
activeItemRef.current?.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||
}, 100)
|
||||
return () => clearTimeout(timer)
|
||||
}, [open, selectedSeason, currentMediaId, versionsForEpisodeId])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
@@ -86,35 +123,109 @@ export function PlayerPlaylistPanel({
|
||||
})
|
||||
: listToDisplay
|
||||
|
||||
const versionsToSwitch = currentVersions.length > 1 ? currentVersions : []
|
||||
// 只有一集(电影/单条媒体)时,面板就是版本选择器,不再重复列出一行「剧集」。
|
||||
const hasEpisodeList = episodes.length > 1
|
||||
const versionsForEpisode = versionsForEpisodeId
|
||||
? (listToDisplay.find((ep) => ep.id === versionsForEpisodeId)?.versions ?? [])
|
||||
: []
|
||||
const expandedEpisode = versionsForEpisodeId
|
||||
? listToDisplay.find((ep) => ep.id === versionsForEpisodeId)
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="absolute inset-x-3 top-12 bottom-3 z-30 flex flex-col overflow-hidden rounded-2xl border border-white/15 bg-black/85 text-white shadow-2xl backdrop-blur-md sm:inset-x-auto sm:right-4 sm:top-16 sm:bottom-20 sm:w-96"
|
||||
// 舞台用 pointerdown/pointerup 判断「移动端轻触画面」,抽屉上的触摸不该参与。
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onPointerUp={(e) => e.stopPropagation()}
|
||||
className={
|
||||
inline
|
||||
? 'flex flex-col'
|
||||
: `absolute inset-y-0 right-0 z-30 ${PLAYER_DRAWER}`
|
||||
}
|
||||
>
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3 shrink-0">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<ListVideo size={17} className="text-rose-400" />
|
||||
<span>选集列表</span>
|
||||
<span className="font-mono text-xs font-normal text-white/50">
|
||||
({episodes.length} 集)
|
||||
</span>
|
||||
{/* 内嵌在竖屏剧场布局的视频下方时不自带标题栏:外层已经提供了
|
||||
「选集 / 收起」标题,重复一层只会挤掉内容。 */}
|
||||
{inline ? null : (
|
||||
<div className={PLAYER_PANEL_HEADER}>
|
||||
<div className="flex min-w-0 items-center gap-2 text-sm font-semibold">
|
||||
<span className="truncate">{hasEpisodeList ? '选集' : '版本'}</span>
|
||||
<span className="shrink-0 font-mono text-[11px] font-normal text-white/45">
|
||||
{hasEpisodeList ? `${episodes.length} 集` : `${versionsToSwitch.length} 个版本`}
|
||||
</span>
|
||||
</div>
|
||||
<button onClick={onClose} className={PLAYER_ICON_BUTTON} title="关闭 (Esc)">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-full p-1 text-white/60 transition hover:bg-white/10 hover:text-white"
|
||||
title="关闭"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={inline ? undefined : 'flex min-h-0 flex-1 flex-col overflow-hidden'}>
|
||||
|
||||
{/* 当前条目的版本切换:多版本时置顶,随时可换 */}
|
||||
{versionsToSwitch.length > 0 && (
|
||||
<div className="shrink-0 border-b border-white/10 px-3 py-2.5">
|
||||
<div className="mb-1.5 flex items-center gap-1.5 text-[11px] font-medium text-white/55">
|
||||
<Layers size={12} className="text-rose-400" />
|
||||
<span>正在播放的版本</span>
|
||||
</div>
|
||||
<div className="max-h-44 space-y-1 overflow-y-auto pr-0.5">
|
||||
{versionsToSwitch.map((version) => (
|
||||
<VersionOption
|
||||
key={version.id}
|
||||
version={version}
|
||||
active={mediaVersionMatches(version, currentMediaId)}
|
||||
onSelect={() => onSelectVersion?.(version)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 某一集的版本列表:从宫格角标展开,选完即收起 */}
|
||||
{expandedEpisode && versionsForEpisode.length > 0 && (
|
||||
<div className="shrink-0 border-b border-white/10 bg-white/[0.03] px-3 py-2.5">
|
||||
<div className="mb-1.5 flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-1.5 text-[11px] font-medium text-amber-200">
|
||||
<Layers size={12} className="shrink-0" />
|
||||
<span className="truncate">
|
||||
{expandedEpisode.episode_num > 0
|
||||
? `第 ${expandedEpisode.episode_num} 集 · 选择版本`
|
||||
: '选择版本'}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setVersionsForEpisodeId(null)}
|
||||
className="shrink-0 rounded p-0.5 text-white/45 transition hover:bg-white/10 hover:text-white"
|
||||
title="收起版本列表"
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-44 space-y-1 overflow-y-auto pr-0.5">
|
||||
{versionsForEpisode.map((version) => (
|
||||
<VersionOption
|
||||
key={version.id}
|
||||
version={version}
|
||||
active={mediaVersionMatches(version, currentMediaId)}
|
||||
compact
|
||||
onSelect={() => {
|
||||
onSelectVersion?.(version)
|
||||
setVersionsForEpisodeId(null)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 季选择 Tabs(若有多季) */}
|
||||
{seasonGroups.length > 1 && (
|
||||
<div className="flex items-center gap-1.5 border-b border-white/10 px-3 py-2 shrink-0 overflow-x-auto no-scrollbar">
|
||||
{hasEpisodeList && seasonGroups.length > 1 && (
|
||||
<div className="scrollbar-hide flex shrink-0 items-center gap-1.5 overflow-x-auto border-b border-white/10 px-3 py-2">
|
||||
{seasonGroups.map(({ season, episodes: sesEps }) => {
|
||||
const isSelected = selectedSeason === season
|
||||
const isPlayingThisSeason = sesEps.some((e) => e.id === currentMediaId)
|
||||
const isPlayingThisSeason = sesEps.some((e) => mediaVersionMatches(e, currentMediaId))
|
||||
return (
|
||||
<button
|
||||
key={season}
|
||||
@@ -140,22 +251,19 @@ export function PlayerPlaylistPanel({
|
||||
)}
|
||||
|
||||
{/* 搜索/过滤单集(当单集数量较多时) */}
|
||||
{listToDisplay.length > 10 && (
|
||||
<div className="px-3 pt-2.5 pb-1.5 shrink-0">
|
||||
<div className="flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-2.5 py-1 text-xs">
|
||||
<Search size={13} className="text-white/40 shrink-0" />
|
||||
{hasEpisodeList && listToDisplay.length > 10 && (
|
||||
<div className="shrink-0 px-3 pb-1 pt-2.5">
|
||||
<div className="flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-2.5 py-1.5 text-xs">
|
||||
<Search size={13} className="shrink-0 text-white/40" />
|
||||
<input
|
||||
type="text"
|
||||
value={filterText}
|
||||
onChange={(e) => setFilterText(e.target.value)}
|
||||
placeholder="搜索集数或标题…"
|
||||
className="w-full bg-transparent outline-none placeholder:text-white/30 text-white text-xs"
|
||||
className="w-full bg-transparent text-xs text-white outline-none placeholder:text-white/30"
|
||||
/>
|
||||
{filterText && (
|
||||
<button
|
||||
onClick={() => setFilterText('')}
|
||||
className="text-white/40 hover:text-white"
|
||||
>
|
||||
<button onClick={() => setFilterText('')} className="text-white/40 hover:text-white">
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
@@ -163,100 +271,153 @@ export function PlayerPlaylistPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 集数列表 */}
|
||||
<div
|
||||
ref={listContainerRef}
|
||||
className="flex-1 overflow-y-auto p-2.5 space-y-1.5 pr-2 select-none"
|
||||
>
|
||||
{filteredEpisodes.length === 0 ? (
|
||||
<div className="py-8 text-center text-xs text-white/40">
|
||||
{filterText ? '未找到匹配的剧集' : '暂无剧集列表'}
|
||||
</div>
|
||||
) : (
|
||||
filteredEpisodes.map((ep) => {
|
||||
const isPlaying = ep.id === currentMediaId
|
||||
const displayTitle = getEpisodeTitle(ep, listToDisplay)
|
||||
const durationText =
|
||||
ep.duration_sec > 0 ? `${Math.floor(ep.duration_sec / 60)} 分钟` : ''
|
||||
{/* 集数宫格:内嵌模式整块跟着外层一起滚动,不再自己滚 */}
|
||||
{hasEpisodeList ? (
|
||||
<div className={inline ? 'p-3 select-none' : 'min-h-0 flex-1 overflow-y-auto p-3 select-none'}>
|
||||
{filteredEpisodes.length === 0 ? (
|
||||
<div className="py-10 text-center text-xs text-white/40">
|
||||
{filterText ? '未找到匹配的剧集' : '暂无剧集列表'}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={
|
||||
inline
|
||||
? 'grid grid-cols-6 gap-1.5'
|
||||
: 'grid grid-cols-5 gap-1.5 sm:grid-cols-6'
|
||||
}
|
||||
>
|
||||
{filteredEpisodes.map((ep) => {
|
||||
const isPlaying = mediaVersionMatches(ep, currentMediaId)
|
||||
const versions = ep.versions && ep.versions.length > 1 ? ep.versions : []
|
||||
const displayTitle = getEpisodeTitle(ep, listToDisplay)
|
||||
const durationText =
|
||||
ep.duration_sec > 0 ? `${Math.round(ep.duration_sec / 60)} 分钟` : ''
|
||||
const cellTitle = [
|
||||
ep.episode_num > 0 ? `第 ${ep.episode_num} 集` : '',
|
||||
displayTitle,
|
||||
durationText,
|
||||
versions.length > 0 ? `${versions.length} 个版本` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
|
||||
return (
|
||||
<div
|
||||
key={ep.id}
|
||||
ref={isPlaying ? activeItemRef : null}
|
||||
onClick={() => onSelectEpisode(ep)}
|
||||
className={`group flex cursor-pointer items-center gap-2.5 rounded-xl p-2 transition border ${
|
||||
isPlaying
|
||||
? 'border-rose-500/60 bg-rose-500/20 text-white'
|
||||
: 'border-white/5 bg-white/5 hover:border-white/20 hover:bg-white/10 text-white/85'
|
||||
}`}
|
||||
>
|
||||
{/* 封面/集号 */}
|
||||
<div className="relative flex h-11 w-16 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-white/10 text-xs font-semibold">
|
||||
{ep.backdrop_url || ep.poster_url ? (
|
||||
<img
|
||||
src={imageURL(ep.backdrop_url || ep.poster_url || '', ep.updated_at)}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : (
|
||||
<Film size={16} className="text-white/40" />
|
||||
)}
|
||||
|
||||
{/* 正在播放动效 / 集数徽标 */}
|
||||
{isPlaying ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/60 backdrop-blur-xs">
|
||||
<div className="flex items-end gap-0.5 h-3">
|
||||
<span className="w-0.5 bg-rose-400 animate-pulse h-full" />
|
||||
<span className="w-0.5 bg-rose-400 animate-pulse h-2" />
|
||||
<span className="w-0.5 bg-rose-400 animate-pulse h-3" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Play size={14} className="text-white fill-white" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 角标显示集数 */}
|
||||
<span className="absolute bottom-0.5 right-1 rounded bg-black/75 px-1 py-0.2 text-[9px] font-mono text-white/90">
|
||||
{ep.episode_num > 0 ? `${ep.episode_num}` : '—'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 标题 & 时长 */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p
|
||||
className={`truncate text-xs font-medium ${
|
||||
isPlaying ? 'text-rose-300 font-semibold' : 'group-hover:text-white'
|
||||
return (
|
||||
<div key={ep.id} className="relative">
|
||||
<button
|
||||
ref={isPlaying ? activeItemRef : null}
|
||||
onClick={() => onSelectEpisode(ep)}
|
||||
title={cellTitle}
|
||||
className={`flex ${inline ? 'h-10' : 'h-9'} w-full items-center justify-center rounded-md text-xs font-medium tabular-nums transition ${
|
||||
isPlaying
|
||||
? 'bg-rose-500 text-white shadow-[0_0_0_1px_rgba(255,255,255,0.2)_inset]'
|
||||
: 'bg-white/[0.06] text-white/80 hover:bg-white/15 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{displayTitle}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5 text-[10px] text-white/50">
|
||||
{ep.episode_num > 0 && (
|
||||
<span className="font-mono">第 {ep.episode_num} 集</span>
|
||||
{isPlaying ? (
|
||||
<Play size={12} className="fill-current" />
|
||||
) : ep.episode_num > 0 ? (
|
||||
ep.episode_num
|
||||
) : (
|
||||
<Film size={13} className="text-white/45" />
|
||||
)}
|
||||
</button>
|
||||
{/* 有多个版本:点角标挑版本,点格子本身直接播放默认版本 */}
|
||||
{versions.length > 0 && (
|
||||
<button
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
setVersionsForEpisodeId((current) =>
|
||||
current === ep.id ? null : ep.id,
|
||||
)
|
||||
}}
|
||||
className={`absolute right-0 top-0 flex ${inline ? 'h-5 w-5 text-[10px]' : 'h-3.5 w-3.5 text-[9px]'} items-center justify-center rounded-bl-md rounded-tr-md font-semibold leading-none transition ${
|
||||
versionsForEpisodeId === ep.id
|
||||
? 'bg-amber-300 text-black'
|
||||
: 'bg-black/45 text-amber-200 hover:bg-black/70'
|
||||
}`}
|
||||
title={`${versions.length} 个版本,点这里选择`}
|
||||
>
|
||||
{versions.length}
|
||||
</button>
|
||||
)}
|
||||
{durationText && <span>{durationText}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isPlaying && (
|
||||
<div className="shrink-0 flex items-center gap-1 text-[11px] font-medium text-rose-400 px-1">
|
||||
<Check size={13} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
versionsToSwitch.length === 0 && (
|
||||
<div className="flex items-center justify-center px-4 py-8 text-center text-xs text-white/40">
|
||||
暂无可切换的内容
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 单个版本选项:主行是画质/容器/体积,次行是来源与文件名。 */
|
||||
function VersionOption({
|
||||
version,
|
||||
active,
|
||||
compact = false,
|
||||
onSelect,
|
||||
}: {
|
||||
version: Media
|
||||
active: boolean
|
||||
compact?: boolean
|
||||
onSelect: () => void
|
||||
}) {
|
||||
const cloud = isStrmMedia(version)
|
||||
const label = mediaVersionLabel(version)
|
||||
const fileName = mediaVersionFileName(version)
|
||||
const sourceLabel = mediaVersionSourceLabel(version)
|
||||
// 没有探测数据时(网盘 STRM 常见)主行本身就是文件名,次行再重复一遍没有意义。
|
||||
const showFileName = fileName !== '' && !labelKey(label).includes(labelKey(fileName))
|
||||
const activeClass = 'border-rose-500/50 bg-rose-500/15 text-white'
|
||||
const idleClass =
|
||||
'border-white/5 bg-white/5 text-white/85 hover:border-white/15 hover:bg-white/10'
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={active}
|
||||
onClick={onSelect}
|
||||
title={version.path || version.strm_url || undefined}
|
||||
className={`flex w-full items-start gap-2 rounded-lg border px-2.5 text-left transition disabled:cursor-default ${
|
||||
compact ? 'py-1.5' : 'py-2'
|
||||
} ${active ? activeClass : idleClass}`}
|
||||
>
|
||||
<span className="mt-0.5 shrink-0">
|
||||
{cloud ? (
|
||||
<Cloud size={13} className={active ? 'text-rose-300' : 'text-white/45'} />
|
||||
) : (
|
||||
<Film size={13} className={active ? 'text-rose-300' : 'text-white/45'} />
|
||||
)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span
|
||||
className={`block truncate text-xs font-medium ${active ? 'text-rose-200' : 'text-white/90'}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-[10px] text-white/45">
|
||||
{sourceLabel}
|
||||
{showFileName ? ` · ${fileName}` : ''}
|
||||
</span>
|
||||
</span>
|
||||
{active && <Check size={13} className="mt-0.5 shrink-0 text-rose-400" />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/** 归一化后比较:忽略大小写与分隔符(. _ - 空格),用于判断两段文案是否只是重复。 */
|
||||
function labelKey(value: string): string {
|
||||
return value.toLowerCase().replace(/[^a-z0-9]+/g, '')
|
||||
}
|
||||
|
||||
function getEpisodeTitle(ep: Media, siblings: Media[]): string {
|
||||
const title = ep.episode_title?.trim()
|
||||
if (title && !looksLikeSeriesTitle(ep, title, siblings)) {
|
||||
|
||||
@@ -0,0 +1,690 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { PointerEvent as ReactPointerEvent, RefObject } from 'react'
|
||||
import { Compass, LogOut, Minus, Plus, RotateCcw, Settings2, Smartphone } from 'lucide-react'
|
||||
|
||||
import {
|
||||
PLAYER_ICON_BUTTON,
|
||||
PLAYER_SEGMENT,
|
||||
PLAYER_SEGMENT_GROUP,
|
||||
PLAYER_SEGMENT_OFF,
|
||||
PLAYER_SEGMENT_ON,
|
||||
PLAYER_TEXT_BUTTON,
|
||||
} from './playerTheme'
|
||||
import {
|
||||
VR360_DEFAULT_FOV,
|
||||
VR360_MAX_FOV,
|
||||
VR360_MIN_FOV,
|
||||
VR360_PROJECTION_OPTIONS,
|
||||
VR360_STEREO_OPTIONS,
|
||||
vr360FrameUv,
|
||||
type Vr360Profile,
|
||||
} from '../utils/vr360'
|
||||
import {
|
||||
applyViewOffset,
|
||||
cameraForward,
|
||||
cameraFromDeviceOrientation,
|
||||
cameraFromYawPitch,
|
||||
clampNumber,
|
||||
degToRad,
|
||||
forwardPitch,
|
||||
forwardYaw,
|
||||
viewMatrixFromCamera,
|
||||
type Mat3,
|
||||
} from '../utils/vr360Math'
|
||||
// 只引入类型:渲染器本体走动态 import,普通播放不会下载 WebGL 全景渲染代码。
|
||||
import type { Vr360Renderer } from '../utils/vr360Renderer'
|
||||
|
||||
// 全景播放舞台:一个覆盖整个播放区域的 WebGL 画布。视频元素本身仍在后台正常
|
||||
// 播放(音轨、进度、弹幕、字幕都不受影响),只是画面被贴到了球面上。
|
||||
//
|
||||
// 视角控制:
|
||||
// 1. 鼠标/手指拖拽 → 转动视角(画面跟随手指),滚轮/双指捏合 → 变焦;
|
||||
// 2. 手机陀螺仪 → 转动设备即转动视角。iOS 13+ 必须在用户手势里调用
|
||||
// DeviceOrientationEvent.requestPermission,所以这里做成显式按钮。
|
||||
|
||||
type ViewState = {
|
||||
/** 拖拽模式下的方位角(弧度,0 = 默认正前方)。 */
|
||||
yaw: number
|
||||
/** 拖拽模式下的俯仰角(弧度,+ 为抬头)。 */
|
||||
pitch: number
|
||||
fov: number
|
||||
/** 陀螺仪是否接管视角。 */
|
||||
gyroActive: boolean
|
||||
/** 最近一次设备姿态对应的相机旋转矩阵。 */
|
||||
deviceCamera: Mat3 | null
|
||||
gyroYawOffset: number
|
||||
gyroPitchOffset: number
|
||||
}
|
||||
|
||||
type PointerState = {
|
||||
x: number
|
||||
y: number
|
||||
startX: number
|
||||
startY: number
|
||||
startAt: number
|
||||
moved: boolean
|
||||
}
|
||||
|
||||
type GyroState = 'off' | 'pending' | 'active' | 'denied' | 'unsupported' | 'unavailable'
|
||||
|
||||
type Vr360StageProps = {
|
||||
videoRef: RefObject<HTMLVideoElement>
|
||||
profile: Vr360Profile
|
||||
/** 控制栏是否可见:不可见时隐藏 VR 自身的浮层按钮。 */
|
||||
uiVisible: boolean
|
||||
/** 轻触画面(未发生拖拽)时回调,与普通播放一致:唤出控制栏或切换播放。 */
|
||||
onSurfaceTap: () => void
|
||||
/**
|
||||
* 第一帧真正画出来时回调,每次挂载只上报一次(改投影方式/画幅布局不会重新上报),
|
||||
* 调用方据此结束「正在启动」提示。
|
||||
*/
|
||||
onReady: () => void
|
||||
onError: (message: string) => void
|
||||
/** 用户调整投影方式/立体布局时回调,由播放页保存为下次进入的默认值。 */
|
||||
onProfileChange?: (profile: Vr360Profile) => void
|
||||
/** 退出 VR 全景播放(回到普通播放)。不提供时工具条上不显示退出入口。 */
|
||||
onExitVr?: () => void
|
||||
/**
|
||||
* 鼠标是否停留在 VR 工具条/设置面板上。由播放页转给控制栏:悬停这些浮层时
|
||||
* 控制栏不应该自动隐藏(否则设置面板会在点击前消失)。
|
||||
*/
|
||||
onUiHoldChange?: (hold: boolean) => void
|
||||
}
|
||||
|
||||
/** 每像素拖拽对应的转角(弧度)。视场角越小时画面越「拉近」,拖拽也应更细腻。 */
|
||||
const DRAG_RAD_PER_PIXEL = 0.0035
|
||||
const TAP_MAX_DISTANCE = 8
|
||||
const TAP_MAX_DURATION_MS = 400
|
||||
const GYRO_PROBE_MS = 1500
|
||||
const PITCH_LIMIT = Math.PI / 2 - 0.03
|
||||
const WHEEL_ZOOM_SPEED = 0.0015
|
||||
|
||||
function screenOrientationAngle(): number {
|
||||
if (
|
||||
typeof screen !== 'undefined' &&
|
||||
screen.orientation &&
|
||||
typeof screen.orientation.angle === 'number'
|
||||
) {
|
||||
return screen.orientation.angle
|
||||
}
|
||||
const legacy = (window as unknown as { orientation?: number }).orientation
|
||||
return typeof legacy === 'number' ? legacy : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前设备是否没有真正的鼠标悬停能力(手机/平板)。
|
||||
*
|
||||
* 触摸设备上「悬停保持显示」这套机制本身就是错的:手指抬起后浏览器不保证补发
|
||||
* mouseleave,一旦走进悬停状态标记就再也清不掉,控制栏和 VR 工具条会永久留在
|
||||
* 画面上。这里按设备能力判断,而不是去猜某个鼠标事件是不是触摸合成的——
|
||||
* 后者依赖 MouseEvent.sourceCapabilities,实测在不少浏览器里是 null,判断会
|
||||
* 静默失效、等于没修。
|
||||
*/
|
||||
function isHoverlessDevice(): boolean {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return true
|
||||
return window.matchMedia('(hover: none)').matches
|
||||
}
|
||||
|
||||
type DeviceOrientationEventConstructor = {
|
||||
requestPermission?: () => Promise<'granted' | 'denied' | 'default'>
|
||||
}
|
||||
|
||||
function gyroStateHint(state: GyroState): string {
|
||||
if (state === 'active') return '陀螺仪已开启:转动设备即可改变视角'
|
||||
if (state === 'pending') return '正在等待陀螺仪数据…'
|
||||
if (state === 'denied') return '陀螺仪权限被拒绝,请在浏览器设置里允许「运动与方向」访问'
|
||||
if (state === 'unavailable') return '没有收到陀螺仪数据,当前设备可能不支持'
|
||||
if (state === 'unsupported') return '当前浏览器不支持陀螺仪'
|
||||
return ''
|
||||
}
|
||||
|
||||
export function Vr360Stage({
|
||||
videoRef,
|
||||
profile,
|
||||
uiVisible,
|
||||
onSurfaceTap,
|
||||
onReady,
|
||||
onError,
|
||||
onProfileChange,
|
||||
onExitVr,
|
||||
onUiHoldChange,
|
||||
}: Vr360StageProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const rendererRef = useRef<Vr360Renderer | null>(null)
|
||||
const viewRef = useRef<ViewState>({
|
||||
yaw: 0,
|
||||
pitch: 0,
|
||||
fov: VR360_DEFAULT_FOV,
|
||||
gyroActive: false,
|
||||
deviceCamera: null,
|
||||
gyroYawOffset: 0,
|
||||
gyroPitchOffset: 0,
|
||||
})
|
||||
const dirtyRef = useRef(true)
|
||||
const readyRef = useRef(false)
|
||||
const pointersRef = useRef(new Map<number, PointerState>())
|
||||
const pinchDistanceRef = useRef<number | null>(null)
|
||||
const alignmentRef = useRef<{ yaw: number; pitch: number } | null>(null)
|
||||
const gyroProbeRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const absoluteSeenRef = useRef(false)
|
||||
const profileRef = useRef(profile)
|
||||
const [gyroState, setGyroState] = useState<GyroState>('off')
|
||||
const [fovHint, setFovHint] = useState(VR360_DEFAULT_FOV)
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
|
||||
// 回调放进 ref:渲染器只在挂载时创建一次,不因父组件重渲染而重建。
|
||||
const callbacksRef = useRef({ onSurfaceTap, onReady, onError })
|
||||
useEffect(() => {
|
||||
callbacksRef.current = { onSurfaceTap, onReady, onError }
|
||||
}, [onSurfaceTap, onReady, onError])
|
||||
|
||||
const markDirty = useCallback(() => {
|
||||
dirtyRef.current = true
|
||||
}, [])
|
||||
|
||||
// 当前相机姿态:陀螺仪接管时以设备姿态为基准叠加拖拽偏移,否则纯拖拽角度。
|
||||
const currentCamera = useCallback((): Mat3 => {
|
||||
const view = viewRef.current
|
||||
if (view.gyroActive && view.deviceCamera) {
|
||||
return applyViewOffset(view.deviceCamera, view.gyroYawOffset, view.gyroPitchOffset)
|
||||
}
|
||||
return cameraFromYawPitch(view.yaw, view.pitch)
|
||||
}, [])
|
||||
|
||||
const draw = useCallback(() => {
|
||||
const renderer = rendererRef.current
|
||||
if (!renderer) return
|
||||
renderer.setView(viewMatrixFromCamera(currentCamera()))
|
||||
renderer.setFov(viewRef.current.fov)
|
||||
renderer.draw()
|
||||
if (!readyRef.current) {
|
||||
// 第一帧真正画出来之后才通知上层:渲染失败时上层会退出 VR 模式。
|
||||
readyRef.current = true
|
||||
callbacksRef.current.onReady()
|
||||
}
|
||||
}, [currentCamera])
|
||||
|
||||
const applyProfile = useCallback((renderer: Vr360Renderer, next: Vr360Profile) => {
|
||||
renderer.setProjection(next.projection)
|
||||
const { scale, offset } = vr360FrameUv(next.stereo)
|
||||
renderer.setFrameUv(scale, offset)
|
||||
dirtyRef.current = true
|
||||
}, [])
|
||||
|
||||
// 创建渲染器(懒加载 WebGL 模块,普通播放不会下载全景渲染代码)。
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
const video = videoRef.current
|
||||
if (!canvas || !video) return
|
||||
let cancelled = false
|
||||
let created: Vr360Renderer | null = null
|
||||
|
||||
void import('../utils/vr360Renderer')
|
||||
.then((module) => {
|
||||
if (cancelled) return
|
||||
created = module.createVr360Renderer(canvas, video, {
|
||||
onContextLost: () => callbacksRef.current.onError('VR 渲染上下文已丢失,已退出 VR 播放'),
|
||||
onTextureError: () => callbacksRef.current.onError('无法读取视频画面用于 VR 渲染'),
|
||||
})
|
||||
if (!created) {
|
||||
callbacksRef.current.onError('当前浏览器不支持 WebGL,无法使用 VR 播放')
|
||||
return
|
||||
}
|
||||
rendererRef.current = created
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
created.resize(rect.width, rect.height)
|
||||
applyProfile(created, profileRef.current)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) callbacksRef.current.onError('VR 渲染组件加载失败')
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
rendererRef.current = null
|
||||
created?.dispose()
|
||||
}
|
||||
}, [applyProfile, videoRef])
|
||||
|
||||
// 投影方式/立体布局变化时重建网格与 UV 裁切。
|
||||
useEffect(() => {
|
||||
profileRef.current = profile
|
||||
const renderer = rendererRef.current
|
||||
if (renderer) applyProfile(renderer, profile)
|
||||
}, [applyProfile, profile])
|
||||
|
||||
// 画布尺寸跟随舞台(窗口缩放、全屏切换、首次布局)。
|
||||
// 观测父容器而不是画布自身:画布尺寸由 CSS 撑满父容器,观测自身会在
|
||||
// 「设置绘制缓冲区分辨率 → 布局变化 → 再次触发观测」之间来回放大。
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const target = canvas.parentElement ?? canvas
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const entry = entries[0]
|
||||
if (!entry) return
|
||||
rendererRef.current?.resize(entry.contentRect.width, entry.contentRect.height)
|
||||
markDirty()
|
||||
})
|
||||
observer.observe(target)
|
||||
const rect = target.getBoundingClientRect()
|
||||
rendererRef.current?.resize(rect.width, rect.height)
|
||||
return () => observer.disconnect()
|
||||
}, [markDirty])
|
||||
|
||||
// 渲染循环:requestVideoFrameCallback 标记「有新画面」,rAF 按需绘制。
|
||||
// 暂停且无输入时不出图,避免持续占用 GPU。
|
||||
useEffect(() => {
|
||||
let handle = 0
|
||||
let cancelled = false
|
||||
const video = videoRef.current as
|
||||
| (HTMLVideoElement & { requestVideoFrameCallback?: (callback: () => void) => number })
|
||||
| null
|
||||
|
||||
if (video && typeof video.requestVideoFrameCallback === 'function') {
|
||||
const onVideoFrame = () => {
|
||||
if (cancelled) return
|
||||
dirtyRef.current = true
|
||||
video.requestVideoFrameCallback?.(onVideoFrame)
|
||||
}
|
||||
video.requestVideoFrameCallback(onVideoFrame)
|
||||
}
|
||||
|
||||
const tick = () => {
|
||||
if (cancelled) return
|
||||
handle = requestAnimationFrame(tick)
|
||||
if (!dirtyRef.current) return
|
||||
dirtyRef.current = false
|
||||
draw()
|
||||
}
|
||||
handle = requestAnimationFrame(tick)
|
||||
return () => {
|
||||
cancelled = true
|
||||
cancelAnimationFrame(handle)
|
||||
}
|
||||
}, [draw, videoRef])
|
||||
|
||||
// 设备姿态 → 相机姿态。第一帧有效数据到达时把视角对齐过去,避免画面跳动。
|
||||
const handleOrientation = useCallback((event: DeviceOrientationEvent) => {
|
||||
if (event.alpha === null && event.beta === null && event.gamma === null) return
|
||||
if (event.type === 'deviceorientationabsolute') absoluteSeenRef.current = true
|
||||
// 同时收到绝对与相对事件时以绝对朝向为准(方位角才有意义)。
|
||||
else if (absoluteSeenRef.current) return
|
||||
|
||||
const camera = cameraFromDeviceOrientation(
|
||||
degToRad(event.alpha ?? 0),
|
||||
degToRad(event.beta ?? 0),
|
||||
degToRad(event.gamma ?? 0),
|
||||
degToRad(screenOrientationAngle()),
|
||||
)
|
||||
const view = viewRef.current
|
||||
view.deviceCamera = camera
|
||||
|
||||
const alignment = alignmentRef.current
|
||||
if (alignment) {
|
||||
const forward = cameraForward(camera)
|
||||
view.gyroYawOffset = alignment.yaw - forwardYaw(forward)
|
||||
view.gyroPitchOffset = clampNumber(alignment.pitch - forwardPitch(forward), -1.2, 1.2)
|
||||
alignmentRef.current = null
|
||||
if (gyroProbeRef.current) {
|
||||
clearTimeout(gyroProbeRef.current)
|
||||
gyroProbeRef.current = null
|
||||
}
|
||||
setGyroState('active')
|
||||
}
|
||||
|
||||
view.gyroActive = true
|
||||
dirtyRef.current = true
|
||||
}, [])
|
||||
|
||||
const stopGyro = useCallback(() => {
|
||||
window.removeEventListener('deviceorientation', handleOrientation)
|
||||
window.removeEventListener('deviceorientationabsolute', handleOrientation)
|
||||
if (gyroProbeRef.current) {
|
||||
clearTimeout(gyroProbeRef.current)
|
||||
gyroProbeRef.current = null
|
||||
}
|
||||
alignmentRef.current = null
|
||||
absoluteSeenRef.current = false
|
||||
}, [handleOrientation])
|
||||
|
||||
const startGyro = useCallback(() => {
|
||||
alignmentRef.current = { yaw: viewRef.current.yaw, pitch: viewRef.current.pitch }
|
||||
absoluteSeenRef.current = false
|
||||
window.addEventListener('deviceorientation', handleOrientation)
|
||||
window.addEventListener('deviceorientationabsolute', handleOrientation)
|
||||
// 桌面浏览器/无陀螺仪设备会一直收不到事件,超时后提示并回退到拖拽模式。
|
||||
gyroProbeRef.current = setTimeout(() => {
|
||||
gyroProbeRef.current = null
|
||||
if (alignmentRef.current) {
|
||||
stopGyro()
|
||||
setGyroState('unavailable')
|
||||
}
|
||||
}, GYRO_PROBE_MS)
|
||||
}, [handleOrientation, stopGyro])
|
||||
|
||||
const toggleGyro = useCallback(() => {
|
||||
if (gyroState === 'active' || gyroState === 'pending') {
|
||||
// 关闭陀螺仪时用当前合成视角续上,画面不会跳。
|
||||
const forward = cameraForward(currentCamera())
|
||||
viewRef.current.yaw = forwardYaw(forward)
|
||||
viewRef.current.pitch = clampNumber(forwardPitch(forward), -PITCH_LIMIT, PITCH_LIMIT)
|
||||
viewRef.current.gyroActive = false
|
||||
viewRef.current.gyroYawOffset = 0
|
||||
viewRef.current.gyroPitchOffset = 0
|
||||
stopGyro()
|
||||
setGyroState('off')
|
||||
markDirty()
|
||||
return
|
||||
}
|
||||
|
||||
const orientationEvent = (
|
||||
window as unknown as { DeviceOrientationEvent?: DeviceOrientationEventConstructor }
|
||||
).DeviceOrientationEvent
|
||||
if (!orientationEvent) {
|
||||
setGyroState('unsupported')
|
||||
return
|
||||
}
|
||||
setGyroState('pending')
|
||||
const requestPermission = orientationEvent.requestPermission
|
||||
if (typeof requestPermission !== 'function') {
|
||||
startGyro()
|
||||
return
|
||||
}
|
||||
// iOS 13+ 必须在用户手势里申请权限,这里由按钮点击直接触发。
|
||||
void requestPermission
|
||||
.call(orientationEvent)
|
||||
.then((result) => {
|
||||
if (result === 'granted') startGyro()
|
||||
else setGyroState('denied')
|
||||
})
|
||||
.catch(() => setGyroState('denied'))
|
||||
}, [currentCamera, gyroState, markDirty, startGyro, stopGyro])
|
||||
|
||||
useEffect(() => () => stopGyro(), [stopGyro])
|
||||
|
||||
// 控制栏/浮层隐藏时收起「画面」设置面板:它属于同一套浮层,下次唤出控制栏时
|
||||
// 不应该自动弹回来挡住画面。
|
||||
useEffect(() => {
|
||||
if (uiVisible) return
|
||||
setSettingsOpen(false)
|
||||
// 浮层不可见时鼠标离开事件不会再触发,这里主动解除「悬停保持显示」。
|
||||
onUiHoldChange?.(false)
|
||||
}, [uiVisible, onUiHoldChange])
|
||||
|
||||
const zoomBy = useCallback(
|
||||
(factor: number) => {
|
||||
const view = viewRef.current
|
||||
view.fov = clampNumber(view.fov * factor, VR360_MIN_FOV, VR360_MAX_FOV)
|
||||
setFovHint(view.fov)
|
||||
markDirty()
|
||||
},
|
||||
[markDirty],
|
||||
)
|
||||
|
||||
const resetView = useCallback(() => {
|
||||
const view = viewRef.current
|
||||
view.yaw = 0
|
||||
view.pitch = 0
|
||||
view.fov = VR360_DEFAULT_FOV
|
||||
view.gyroYawOffset = 0
|
||||
view.gyroPitchOffset = 0
|
||||
setFovHint(view.fov)
|
||||
markDirty()
|
||||
}, [markDirty])
|
||||
|
||||
const pinchDistance = useCallback((): number => {
|
||||
const pointers = [...pointersRef.current.values()]
|
||||
if (pointers.length < 2) return 0
|
||||
return Math.hypot(pointers[0].x - pointers[1].x, pointers[0].y - pointers[1].y)
|
||||
}, [])
|
||||
|
||||
const handlePointerDown = (event: ReactPointerEvent<HTMLCanvasElement>) => {
|
||||
// 轻触画面把视角拖动之外的浮层悬停状态清掉:触摸设备不会再发出 mouseleave,
|
||||
// 上一步点击工具条/设置面板留下的「悬停保持显示」会一直挂着,控制栏就永远
|
||||
// 不隐藏了。用户已经在点画面,说明指针早已离开浮层。
|
||||
if (event.pointerType === 'touch') onUiHoldChange?.(false)
|
||||
try {
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId)
|
||||
} catch {
|
||||
// 某些环境(合成事件、非活跃指针)会抛 InvalidPointerId,退化为普通拖拽即可。
|
||||
}
|
||||
pointersRef.current.set(event.pointerId, {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
startAt: Date.now(),
|
||||
moved: false,
|
||||
})
|
||||
if (pointersRef.current.size === 2) {
|
||||
pinchDistanceRef.current = pinchDistance()
|
||||
// 双指手势一律不算轻触,避免捏合缩放时误触发播放/暂停。
|
||||
for (const pointer of pointersRef.current.values()) pointer.moved = true
|
||||
}
|
||||
}
|
||||
|
||||
const handlePointerMove = (event: ReactPointerEvent<HTMLCanvasElement>) => {
|
||||
const pointer = pointersRef.current.get(event.pointerId)
|
||||
if (!pointer) return
|
||||
const dx = event.clientX - pointer.x
|
||||
const dy = event.clientY - pointer.y
|
||||
pointer.x = event.clientX
|
||||
pointer.y = event.clientY
|
||||
if (
|
||||
Math.hypot(event.clientX - pointer.startX, event.clientY - pointer.startY) > TAP_MAX_DISTANCE
|
||||
) {
|
||||
pointer.moved = true
|
||||
}
|
||||
|
||||
if (pointersRef.current.size >= 2) {
|
||||
const distance = pinchDistance()
|
||||
const previous = pinchDistanceRef.current
|
||||
pinchDistanceRef.current = distance
|
||||
if (previous && distance > 0) zoomBy(previous / distance)
|
||||
return
|
||||
}
|
||||
|
||||
const view = viewRef.current
|
||||
const scale = DRAG_RAD_PER_PIXEL * (view.fov / VR360_DEFAULT_FOV)
|
||||
if (view.gyroActive && view.deviceCamera) {
|
||||
view.gyroYawOffset -= dx * scale
|
||||
view.gyroPitchOffset = clampNumber(
|
||||
view.gyroPitchOffset + dy * scale,
|
||||
-PITCH_LIMIT,
|
||||
PITCH_LIMIT,
|
||||
)
|
||||
} else {
|
||||
view.yaw -= dx * scale
|
||||
view.pitch = clampNumber(view.pitch + dy * scale, -PITCH_LIMIT, PITCH_LIMIT)
|
||||
}
|
||||
markDirty()
|
||||
}
|
||||
|
||||
const endPointer = (event: ReactPointerEvent<HTMLCanvasElement>) => {
|
||||
const pointer = pointersRef.current.get(event.pointerId)
|
||||
pointersRef.current.delete(event.pointerId)
|
||||
if (pointersRef.current.size < 2) pinchDistanceRef.current = null
|
||||
if (!pointer) return
|
||||
const isTap =
|
||||
!pointer.moved &&
|
||||
Date.now() - pointer.startAt <= TAP_MAX_DURATION_MS &&
|
||||
pointersRef.current.size === 0
|
||||
if (isTap) onSurfaceTap()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const onWheel = (event: WheelEvent) => {
|
||||
event.preventDefault()
|
||||
zoomBy(1 + event.deltaY * WHEEL_ZOOM_SPEED)
|
||||
}
|
||||
canvas.addEventListener('wheel', onWheel, { passive: false })
|
||||
return () => canvas.removeEventListener('wheel', onWheel)
|
||||
}, [zoomBy])
|
||||
|
||||
const hint = gyroStateHint(gyroState)
|
||||
|
||||
return (
|
||||
<>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
data-vr360-surface
|
||||
className={`absolute inset-0 h-full w-full touch-none select-none ${
|
||||
// 浮层隐藏时连鼠标指针一起藏起来:全景画面里光标一直悬在画面中间很出戏。
|
||||
uiVisible ? 'cursor-grab active:cursor-grabbing' : 'cursor-none'
|
||||
}`}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={endPointer}
|
||||
onPointerCancel={endPointer}
|
||||
// 轻触由指针逻辑自己处理(拖动时不能误触发播放/暂停),
|
||||
// 同时阻止冒泡到舞台的点击逻辑,避免一次触摸切换两次播放状态。
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
/>
|
||||
<div
|
||||
// 阻止浮层内按钮/面板的点击冒泡到舞台的 handleSurfaceActivate:否则点击
|
||||
// 投影方式/画幅布局等设置项会被误判为「单击收起控制栏」,面板在切换后立即消失。
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
className={`pointer-events-none absolute inset-x-0 top-2 z-20 flex flex-col items-center gap-1.5 px-2 transition-opacity duration-300 ${
|
||||
uiVisible ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex flex-wrap items-center justify-center gap-0.5 rounded-xl border border-white/10 bg-[#0d0e12]/90 p-1 text-white shadow-[0_10px_38px_rgba(0,0,0,0.6)] backdrop-blur-xl ${
|
||||
// 浮层隐藏时必须彻底不接收指针事件:否则看不见的按钮既会在画面顶部
|
||||
// 抢走拖动转视角的手势,也可能被误点(例如误开陀螺仪、误开设置面板)。
|
||||
uiVisible ? 'pointer-events-auto' : 'pointer-events-none'
|
||||
}`}
|
||||
// 触摸设备不参与「悬停保持显示」:手指抬起后不会补发 mouseleave,
|
||||
// 标记会永久挂在打开状态,控制栏和 VR 工具条就再也不自动隐藏。
|
||||
onMouseEnter={() => {
|
||||
if (isHoverlessDevice()) return
|
||||
onUiHoldChange?.(true)
|
||||
}}
|
||||
onMouseLeave={() => onUiHoldChange?.(false)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleGyro}
|
||||
className={`${PLAYER_TEXT_BUTTON} ${
|
||||
gyroState === 'active' ? 'bg-rose-500 text-white hover:bg-rose-500' : ''
|
||||
}`}
|
||||
title="用手机陀螺仪转动视角"
|
||||
>
|
||||
<Smartphone size={14} />
|
||||
陀螺仪
|
||||
{gyroState === 'active' && <Compass size={12} className="text-white/85" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetView}
|
||||
className={PLAYER_TEXT_BUTTON}
|
||||
title="恢复默认视角与视场角"
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
重置
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSettingsOpen((open) => !open)}
|
||||
className={`${PLAYER_TEXT_BUTTON} ${settingsOpen ? 'bg-white/15 text-white' : ''}`}
|
||||
title="投影方式与画幅布局"
|
||||
>
|
||||
<Settings2 size={14} />
|
||||
画面
|
||||
</button>
|
||||
<div className="mx-0.5 h-5 w-px bg-white/10" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => zoomBy(1.15)}
|
||||
className={PLAYER_ICON_BUTTON}
|
||||
title="视野拉远"
|
||||
>
|
||||
<Minus size={15} />
|
||||
</button>
|
||||
<span className="w-9 text-center text-[10px] tabular-nums text-white/70">
|
||||
{(VR360_DEFAULT_FOV / fovHint).toFixed(1)}×
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => zoomBy(1 / 1.15)}
|
||||
className={PLAYER_ICON_BUTTON}
|
||||
title="视野拉近"
|
||||
>
|
||||
<Plus size={15} />
|
||||
</button>
|
||||
{onExitVr ? (
|
||||
<>
|
||||
<div className="mx-0.5 h-5 w-px bg-white/10" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={onExitVr}
|
||||
className={`${PLAYER_TEXT_BUTTON} hover:bg-rose-500/20 hover:text-rose-200`}
|
||||
title="退出 VR 全景,回到普通播放"
|
||||
>
|
||||
<LogOut size={14} />
|
||||
退出 VR
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="pointer-events-none rounded-md bg-black/45 px-3 py-1 text-[10px] text-white/65 backdrop-blur">
|
||||
{hint || '拖动旋转视角 · 滚轮或双指缩放'}
|
||||
</p>
|
||||
{settingsOpen ? (
|
||||
<div
|
||||
className={`w-[min(92vw,420px)] rounded-xl border border-white/10 bg-[#0d0e12]/95 px-3 py-3 text-white shadow-[0_10px_38px_rgba(0,0,0,0.6)] backdrop-blur-xl ${
|
||||
uiVisible ? 'pointer-events-auto' : 'pointer-events-none'
|
||||
}`}
|
||||
onMouseEnter={() => {
|
||||
if (isHoverlessDevice()) return
|
||||
onUiHoldChange?.(true)
|
||||
}}
|
||||
onMouseLeave={() => onUiHoldChange?.(false)}
|
||||
>
|
||||
<p className="mb-1.5 text-[10px] uppercase tracking-wide text-white/40">投影方式</p>
|
||||
<div className={PLAYER_SEGMENT_GROUP}>
|
||||
{VR360_PROJECTION_OPTIONS.map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => onProfileChange?.({ ...profile, projection: value })}
|
||||
className={`${PLAYER_SEGMENT} ${
|
||||
profile.projection === value ? PLAYER_SEGMENT_ON : PLAYER_SEGMENT_OFF
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="mb-1.5 mt-3 text-[10px] uppercase tracking-wide text-white/40">
|
||||
画幅布局
|
||||
</p>
|
||||
<div className={PLAYER_SEGMENT_GROUP}>
|
||||
{VR360_STEREO_OPTIONS.map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => onProfileChange?.({ ...profile, stereo: value })}
|
||||
className={`${PLAYER_SEGMENT} ${
|
||||
profile.stereo === value ? PLAYER_SEGMENT_ON : PLAYER_SEGMENT_OFF
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{profile.stereo !== 'mono' && (
|
||||
<p className="mt-2.5 text-[10px] leading-relaxed text-white/45">
|
||||
左右/上下并排素材在当前屏幕上只显示左眼画面;真正的双眼立体画面需要 VR
|
||||
头显(WebXR)。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* 播放器浮层的统一视觉语言。
|
||||
*
|
||||
* 播放器里现在有十几种浮层:底部操作栏、清晰度/倍速/字幕小弹层、设置面板、
|
||||
* 选集抽屉、弹幕面板、VR 工具条、错误提示……以前每一处都自己写一遍圆角、
|
||||
* 边框、透明度,于是同一屏里出现了好几种不同的浮层样式,看起来就「乱」。
|
||||
*
|
||||
* 这里把冒泡、排版、图标按钮等重复的类名收敛成常量:改一次全部同步,
|
||||
* 新加控件也不会再各写一套。
|
||||
*/
|
||||
|
||||
/** 面板底色。比纯黑更「实」,配合 backdrop-blur 后压在画面上仍然清晰可读。 */
|
||||
const PANEL_BG = 'bg-[#0d0e12]/95'
|
||||
|
||||
/** 浮层外圈阴影:暗背景下用大范围柔光,避免出现一圈生硬的黑边。 */
|
||||
const PANEL_SHADOW = 'shadow-[0_10px_38px_rgba(0,0,0,0.6)]'
|
||||
|
||||
/** 小弹层(清晰度/倍速/字幕/设置):贴着操作栏向上弹出。 */
|
||||
export const PLAYER_POPOVER =
|
||||
`z-40 overflow-hidden rounded-xl border border-white/10 ${PANEL_BG} py-1 text-white ${PANEL_SHADOW} backdrop-blur-xl`
|
||||
|
||||
/**
|
||||
* 右侧抽屉(选集/弹幕设置):整条贴住播放区域右边缘,通到顶底。
|
||||
*
|
||||
* 小屏上留出 12% 的宽度而不是铺满:这样顶栏的返回按钮和标题仍然露在外面,
|
||||
* 用户不用先关抽屉才能返回(铺满时唯一的出口只剩标题栏上的关闭按钮)。
|
||||
*/
|
||||
export const PLAYER_DRAWER =
|
||||
`flex h-full w-[88%] flex-col border-white/10 ${PANEL_BG} text-white ${PANEL_SHADOW} backdrop-blur-xl sm:w-[380px] sm:border-l`
|
||||
|
||||
/**
|
||||
* 底部动作面板(竖屏剧场模式):贴着整个屏幕底部向上弹出,高度按视口
|
||||
* 计算(最高 86dvh),因此手机上能容纳的选项远多于贴视频区弹出的版本。
|
||||
*
|
||||
* 用 fixed 而不是 absolute:播放区在竖屏剧场模式里只有 16:9 一小块,
|
||||
* 若以它为定位基准,面板最高只能到视频高度的 72%,内容会被压得很窄。
|
||||
*/
|
||||
export const PLAYER_SHEET =
|
||||
`player-sheet-height fixed inset-x-0 bottom-0 z-40 flex min-h-[10rem] flex-col overflow-hidden rounded-t-2xl border border-b-0 border-white/10 ${PANEL_BG} text-white ${PANEL_SHADOW} backdrop-blur-xl`
|
||||
|
||||
/** 底部动作面板的标题栏(带关闭按钮)。 */
|
||||
export const PLAYER_SHEET_HEADER =
|
||||
'flex shrink-0 items-center justify-between gap-2 border-b border-white/10 px-4 py-2.5'
|
||||
|
||||
/**
|
||||
* 底部动作面板的可滚动内容区:底部垫上安全区高度避免 iPhone 小横条遮挡,
|
||||
* 并把面板内所有按钮抬到 40px,手机上手指点得准。
|
||||
*/
|
||||
export const PLAYER_SHEET_BODY =
|
||||
'min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 pb-[calc(0.75rem+env(safe-area-inset-bottom,0px))] pt-2 [&_button]:min-h-10'
|
||||
|
||||
/** 操作栏里的图标按钮。所有图标按钮共用同一尺寸与悬停反馈,排在一起才整齐。 */
|
||||
export const PLAYER_ICON_BUTTON =
|
||||
'flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-white/85 transition hover:bg-white/15 hover:text-white disabled:cursor-not-allowed disabled:text-white/30 disabled:hover:bg-transparent sm:h-8 sm:w-8'
|
||||
|
||||
/** 带文字的按钮(清晰度/倍速/选集)。文字按钮和图标按钮同高,基线才对得齐。 */
|
||||
export const PLAYER_TEXT_BUTTON =
|
||||
'flex h-7 shrink-0 items-center gap-1 rounded-md px-1.5 text-[11px] font-medium text-white/85 transition hover:bg-white/15 hover:text-white disabled:cursor-not-allowed disabled:text-white/30 disabled:hover:bg-transparent sm:h-8 sm:px-2 sm:text-xs'
|
||||
|
||||
/** 弹层/面板里的一行选项。 */
|
||||
export const PLAYER_MENU_ITEM =
|
||||
'flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-xs text-white/85 transition hover:bg-white/10 hover:text-white'
|
||||
|
||||
/** 弹层里的小标题(「115 云端」「本地 HLS」这类分组名)。 */
|
||||
export const PLAYER_MENU_LABEL =
|
||||
'px-3 pb-1 pt-2 text-[10px] uppercase tracking-wide text-white/35'
|
||||
|
||||
/** 分组之间的分隔线。 */
|
||||
export const PLAYER_MENU_DIVIDER = 'my-1 h-px bg-white/10'
|
||||
|
||||
/** 抽屉/面板的标题栏。 */
|
||||
export const PLAYER_PANEL_HEADER =
|
||||
'flex shrink-0 items-center justify-between gap-2 border-b border-white/10 px-4 py-3'
|
||||
|
||||
/** 分段选择(播放方式、投影方式这类互斥选项)。 */
|
||||
export const PLAYER_SEGMENT_GROUP = 'flex items-center gap-1 rounded-lg bg-white/5 p-0.5'
|
||||
export const PLAYER_SEGMENT = 'flex-1 rounded-md px-2 py-1 text-[11px] font-medium transition'
|
||||
export const PLAYER_SEGMENT_ON = 'bg-rose-500 text-white'
|
||||
export const PLAYER_SEGMENT_OFF = 'text-white/70 hover:bg-white/10 hover:text-white'
|
||||
|
||||
/** 开关状态的绿色小点(例如弹幕已加载、VR 素材已识别)。 */
|
||||
export const PLAYER_STATUS_DOT = 'h-1.5 w-1.5 shrink-0 rounded-full'
|
||||
|
||||
/**
|
||||
* 进度条/音量条上的隐形 range 输入。
|
||||
*
|
||||
* 视觉部分(轨道、已播放段、圆点手柄)全部自己画,原生 range 只负责三件事:
|
||||
* 点击定位、拖拽、键盘与无障碍语义。所以它必须完全透明——但**不能**
|
||||
* pointer-events-none,否则拖不动。
|
||||
*/
|
||||
export const PLAYER_RANGE_OVERLAY =
|
||||
'absolute inset-0 h-full w-full cursor-pointer appearance-none bg-transparent outline-none ' +
|
||||
'[&::-webkit-slider-runnable-track]:bg-transparent ' +
|
||||
'[&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:appearance-none ' +
|
||||
'[&::-moz-range-track]:bg-transparent ' +
|
||||
'[&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:border-0 ' +
|
||||
'[&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:bg-transparent'
|
||||
|
||||
/** 自绘的细轨道。悬停时整条轨道加粗,是 B 站进度条最直观的那点反馈。 */
|
||||
export const PLAYER_TRACK =
|
||||
'pointer-events-none absolute inset-x-0 h-[3px] rounded-full bg-white/25 transition-[height] duration-150'
|
||||
export const PLAYER_TRACK_FILL = 'pointer-events-none absolute inset-y-0 left-0 rounded-full'
|
||||
@@ -5,7 +5,6 @@ import type { User } from '../types'
|
||||
|
||||
export function useLayoutPermissions(user: User | null | undefined) {
|
||||
const permissions = usePermissionStore((state) => state.permissions)
|
||||
const isSuper = usePermissionStore((state) => state.isSuper)
|
||||
const isPermissionLoading = usePermissionStore((state) => state.isLoading)
|
||||
const fetchPermissions = usePermissionStore((state) => state.fetchPermissions)
|
||||
|
||||
@@ -17,8 +16,8 @@ export function useLayoutPermissions(user: User | null | undefined) {
|
||||
|
||||
const isAdmin = user?.role === 'admin'
|
||||
const can = useCallback(
|
||||
(key: string) => isAdmin || isSuper || (permissions ?? {})[key] === true,
|
||||
[isAdmin, isSuper, permissions],
|
||||
(key: string) => isAdmin || (permissions ?? {})[key] === true,
|
||||
[isAdmin, permissions],
|
||||
)
|
||||
|
||||
return { can, isAdmin }
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useCallback, useState, type DragEvent } from 'react'
|
||||
|
||||
/** 一组可直接展开到可拖拽元素上的事件处理器。 */
|
||||
export type DragReorderHandlers = {
|
||||
draggable: boolean
|
||||
onDragStart: (event: DragEvent) => void
|
||||
onDragOver: (event: DragEvent) => void
|
||||
onDragLeave: (event: DragEvent) => void
|
||||
onDrop: (event: DragEvent) => void
|
||||
onDragEnd: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 `fromId` 移动到 `toId` 当前所在的位置,返回新数组;不改动入参。
|
||||
* 任一 id 不存在,或两者相同时返回 null,表示无需重排。
|
||||
*/
|
||||
export function moveIdToPosition(ids: string[], fromId: string, toId: string): string[] | null {
|
||||
const fromIndex = ids.indexOf(fromId)
|
||||
const toIndex = ids.indexOf(toId)
|
||||
if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) return null
|
||||
const next = [...ids]
|
||||
const [moved] = next.splice(fromIndex, 1)
|
||||
next.splice(toIndex, 0, moved)
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表拖拽排序的通用状态机:只负责「一串稳定 id」的拖拽与重排计算,
|
||||
* 不关心渲染。调用方把 `dragProps(id)` 展开到拖拽柄上,并用返回的
|
||||
* `draggingId` / `dragOverId` 决定高亮样式。
|
||||
*
|
||||
* 落点语义:把被拖项插入到目标项所在的位置。未传 `onReorder` 时整体禁用。
|
||||
*/
|
||||
export function useDragReorder(
|
||||
ids: string[],
|
||||
onReorder?: (orderedIds: string[]) => void | Promise<void>,
|
||||
): {
|
||||
draggingId: string | null
|
||||
dragOverId: string | null
|
||||
dragProps: (id: string) => DragReorderHandlers
|
||||
} {
|
||||
const [draggingId, setDraggingId] = useState<string | null>(null)
|
||||
const [dragOverId, setDragOverId] = useState<string | null>(null)
|
||||
|
||||
const enabled = Boolean(onReorder)
|
||||
|
||||
const dragProps = useCallback(
|
||||
(id: string): DragReorderHandlers => ({
|
||||
draggable: enabled,
|
||||
onDragStart: (event) => {
|
||||
if (!enabled) return
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
event.dataTransfer.setData('text/plain', id)
|
||||
setDraggingId(id)
|
||||
setDragOverId(null)
|
||||
},
|
||||
onDragOver: (event) => {
|
||||
if (!enabled || draggingId === id) return
|
||||
event.preventDefault()
|
||||
event.dataTransfer.dropEffect = 'move'
|
||||
setDragOverId((prev) => (prev === id ? prev : id))
|
||||
},
|
||||
onDragLeave: () => {
|
||||
setDragOverId((prev) => (prev === id ? null : prev))
|
||||
},
|
||||
onDrop: (event) => {
|
||||
if (!enabled) return
|
||||
event.preventDefault()
|
||||
// 拖拽期间 state 可能尚未提交,优先信任 dataTransfer 里的来源 id。
|
||||
const fromId = draggingId ?? event.dataTransfer.getData('text/plain')
|
||||
setDraggingId(null)
|
||||
setDragOverId(null)
|
||||
if (!fromId || fromId === id) return
|
||||
const next = moveIdToPosition(ids, fromId, id)
|
||||
if (next) void onReorder?.(next)
|
||||
},
|
||||
onDragEnd: () => {
|
||||
setDraggingId(null)
|
||||
setDragOverId(null)
|
||||
},
|
||||
}),
|
||||
[enabled, draggingId, ids, onReorder],
|
||||
)
|
||||
|
||||
return { draggingId, dragOverId, dragProps }
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user