From 61a7260eafc098ccd9abdf13a60bf49d9286effe Mon Sep 17 00:00:00 2001 From: truewhile <779943132@qq.com> Date: Fri, 18 Sep 2026 21:55:12 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=EF=BC=8C=E7=8E=B0=E6=9C=89bu?= =?UTF-8?q?g=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/reading-module-design.md | 743 ++++++++++++++++++ internal/handler/emby_items_handlers.go | 27 + .../handler/emby_show_episodes_season_test.go | 211 +++++ internal/service/cloud115_playback.go | 22 +- internal/service/emby_compat.go | 4 + internal/service/emby_items_cache.go | 10 + internal/service/emby_items_list.go | 27 +- internal/service/emby_season_index_test.go | 231 ++++++ internal/service/emby_series_ids.go | 10 + internal/service/ffprobe.go | 34 + internal/service/manual_scrape_test.go | 4 +- internal/service/nfo.go | 42 +- internal/service/nfo_export_duplicate_test.go | 145 ++++ .../organizer_reclassify_metadata_test.go | 8 +- .../organizer_scrape_providers_test.go | 4 +- internal/service/playback_quality.go | 22 +- internal/service/playback_quality_test.go | 29 +- internal/service/recognition_words_test.go | 4 +- internal/service/runtime_cache.go | 40 +- .../service/runtime_cache_eviction_test.go | 108 +++ internal/service/sanitize_dotdir_test.go | 79 ++ internal/service/scanner_incremental_test.go | 6 +- internal/service/scanner_local_ingest.go | 41 + .../service/scanner_local_metadata_test.go | 2 +- .../service/scanner_probe_backfill_test.go | 167 ++++ internal/service/scraper_query_case_test.go | 68 ++ internal/service/scraper_query_clean.go | 45 +- .../scraper_query_clean_regression_test.go | 18 +- internal/service/scraper_query_movie_test.go | 13 +- internal/service/scraper_query_test.go | 22 +- internal/service/service_builder.go | 2 + internal/service/strm_service.go | 8 +- internal/service/test_db_test.go | 28 +- internal/service/transcoder.go | 36 + .../service/transcoder_availability_test.go | 67 ++ internal/service/walk.go | 14 +- internal/service/walk_dotdir_test.go | 115 +++ web/src/components/MediaCard.tsx | 30 +- web/src/pages/AdminUsersTable.tsx | 22 +- web/src/pages/DlnaPage.tsx | 4 +- web/src/pages/PlayerPage.tsx | 7 + web/src/pages/ProfileManagementPage.tsx | 2 +- 42 files changed, 2429 insertions(+), 92 deletions(-) create mode 100644 docs/reading-module-design.md create mode 100644 internal/handler/emby_show_episodes_season_test.go create mode 100644 internal/service/emby_season_index_test.go create mode 100644 internal/service/nfo_export_duplicate_test.go create mode 100644 internal/service/runtime_cache_eviction_test.go create mode 100644 internal/service/sanitize_dotdir_test.go create mode 100644 internal/service/scanner_probe_backfill_test.go create mode 100644 internal/service/scraper_query_case_test.go create mode 100644 internal/service/transcoder_availability_test.go create mode 100644 internal/service/walk_dotdir_test.go diff --git a/docs/reading-module-design.md b/docs/reading-module-design.md new file mode 100644 index 0000000..7706d7c --- /dev/null +++ b/docs/reading-module-design.md @@ -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 的 `` 或首个 `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 数组 +``` diff --git a/internal/handler/emby_items_handlers.go b/internal/handler/emby_items_handlers.go index f2b2f61..4152a00 100644 --- a/internal/handler/emby_items_handlers.go +++ b/internal/handler/emby_items_handlers.go @@ -40,9 +40,28 @@ func parseEmbyItemsParams(c *gin.Context) service.ItemsParams { SortOrder: firstQueryValue(c, "SortOrder", "sortOrder", "sortorder"), Limit: limit, StartIndex: offset, + SeasonIndex: parseEmbySeasonIndexQuery(c), } } +// parseEmbySeasonIndexQuery 读取客户端请求的季序号。 +// +// Emby 客户端有两种表达方式:SeasonId(虚拟季 ID)与 Season / SeasonIndex +// (季序号,特别篇为 0)。两者都是合法入参,SeasonId 更精确。这里只解析季序号, +// 返回 nil 表示客户端没有按季过滤(区别于 Season=0 的特别篇)。 +func parseEmbySeasonIndexQuery(c *gin.Context) *int { + raw := firstQueryValue(c, "Season", "season", "SeasonIndex", "seasonIndex", "seasonindex") + if raw == "" { + return nil + } + value, err := strconv.Atoi(raw) + if err != nil { + // 客户端偶尔传入季名称之类的非数字值;按「未过滤」处理,避免整季空结果。 + return nil + } + return &value +} + func embyFirstNonEmptyString(values ...string) string { for _, value := range values { if strings.TrimSpace(value) != "" { @@ -199,8 +218,15 @@ func embyShowSeasonsHandler(svc *service.Container) gin.HandlerFunc { func embyShowEpisodesHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { parentID := firstQueryValue(c, "SeasonId", "seasonId") + // 客户端常用季序号而不是虚拟季 ID 请求剧集。缺了这层过滤, + // /Shows/{id}/Episodes?Season=2 会把整部剧的所有季都返回。 + seasonIndex := parseEmbySeasonIndexQuery(c) if parentID == "" { parentID = c.Param("id") + } else { + // SeasonId 已经限定了具体季,忽略同时传来的季序号,避免两者 + // 不一致时把结果过滤成空集。 + seasonIndex = nil } params := service.ItemsParams{ UserID: embyEffectiveUserID(c), @@ -208,6 +234,7 @@ func embyShowEpisodesHandler(svc *service.Container) gin.HandlerFunc { IncludeItemTypes: []string{"Episode"}, Recursive: true, Limit: 500, + SeasonIndex: seasonIndex, } out, err := svc.Emby.Items(c.Request.Context(), params) if err != nil { diff --git a/internal/handler/emby_show_episodes_season_test.go b/internal/handler/emby_show_episodes_season_test.go new file mode 100644 index 0000000..a556ef5 --- /dev/null +++ b/internal/handler/emby_show_episodes_season_test.go @@ -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) + } +} diff --git a/internal/service/cloud115_playback.go b/internal/service/cloud115_playback.go index 8f24059..7923f12 100644 --- a/internal/service/cloud115_playback.go +++ b/internal/service/cloud115_playback.go @@ -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, diff --git a/internal/service/emby_compat.go b/internal/service/emby_compat.go index a6204dd..e1f14b1 100644 --- a/internal/service/emby_compat.go +++ b/internal/service/emby_compat.go @@ -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 ( diff --git a/internal/service/emby_items_cache.go b/internal/service/emby_items_cache.go index f16ad53..a31a030 100644 --- a/internal/service/emby_items_cache.go +++ b/internal/service/emby_items_cache.go @@ -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)}, "|"))) diff --git a/internal/service/emby_items_list.go b/internal/service/emby_items_list.go index 0d04187..963c9b9 100644 --- a/internal/service/emby_items_list.go +++ b/internal/service/emby_items_list.go @@ -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 diff --git a/internal/service/emby_season_index_test.go b/internal/service/emby_season_index_test.go new file mode 100644 index 0000000..3281c1c --- /dev/null +++ b/internal/service/emby_season_index_test.go @@ -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 } diff --git a/internal/service/emby_series_ids.go b/internal/service/emby_series_ids.go index f2be6d8..2db650d 100644 --- a/internal/service/emby_series_ids.go +++ b/internal/service/emby_series_ids.go @@ -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} diff --git a/internal/service/ffprobe.go b/internal/service/ffprobe.go index b332064..beb6889 100644 --- a/internal/service/ffprobe.go +++ b/internal/service/ffprobe.go @@ -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 diff --git a/internal/service/manual_scrape_test.go b/internal/service/manual_scrape_test.go index 0670fb5..22d7743 100644 --- a/internal/service/manual_scrape_test.go +++ b/internal/service/manual_scrape_test.go @@ -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) } } diff --git a/internal/service/nfo.go b/internal/service/nfo.go index 00d0cc0..23fd291 100644 --- a/internal/service/nfo.go +++ b/internal/service/nfo.go @@ -76,7 +76,10 @@ type episodeNFO struct { } // ExportOne writes a movie.nfo file next to the media file. Existing files -// are overwritten so a re-scrape always reflects the latest metadata. +// are overwritten so a re-scrape always reflects the latest metadata. When the +// folder already holds a sidecar the reader would pick (movie.nfo / +// <dirname>.nfo), that file is updated in place instead of adding a second, +// divergent NFO. func (s *NFOService) ExportOne(ctx context.Context, mediaID string) (string, error) { m, err := s.repo.Media.FindByID(ctx, mediaID) if err != nil { @@ -191,12 +194,49 @@ func WriteMediaNFO(m *model.Media) (string, error) { return "", err } dst := nfoPath(resolveMappedDestinationPath(m.Path)) + if m.SeasonNum <= 0 && m.EpisodeNum <= 0 { + dst = nfoExportTarget(dst) + } if err := os.WriteFile(dst, []byte(xml.Header+string(out)+"\n"), 0o644); err != nil { // #nosec G306 -- NFO sidecars must remain readable by media players. return "", err } return dst, nil } +// nfoExportTarget picks the file an export should write for a movie. +// +// Besides the canonical "<base>.nfo", the reader (findMovieNFO) also accepts +// movie.nfo and "<dirname>.nfo". Writing the canonical name into a folder that +// already carries one of those left two divergent NFOs for the same movie, and +// because the reader prefers the canonical one the pre-existing file quietly went +// stale. Reuse whichever file the reader would already have picked, in the same +// precedence order, so an export updates the existing sidecar in place. +// +// Episodes are deliberately excluded by the caller: for an episode the +// alternatives are series-level files, and writing episode metadata there would +// corrupt the series identity. +func nfoExportTarget(canonical string) string { + dir := filepath.Dir(canonical) + candidates := []string{ + filepath.Base(canonical), + "movie.nfo", + filepath.Base(dir) + ".nfo", + } + for _, name := range candidates { + if name == "" || name == "." { + continue + } + candidate := filepath.Join(dir, name) + if candidate == canonical { + continue + } + if info, err := os.Stat(candidate); err == nil && !info.IsDir() { + return candidate + } + } + return canonical +} + func splitNFOList(value string) []string { parts := strings.Split(value, ",") out := make([]string, 0, len(parts)) diff --git a/internal/service/nfo_export_duplicate_test.go b/internal/service/nfo_export_duplicate_test.go new file mode 100644 index 0000000..00c8b29 --- /dev/null +++ b/internal/service/nfo_export_duplicate_test.go @@ -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>旧标题`) + + 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 .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 .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, `old`) + + 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 / +// .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, `series level`) + + 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 .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, `old`) + + 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) + } +} diff --git a/internal/service/organizer_reclassify_metadata_test.go b/internal/service/organizer_reclassify_metadata_test.go index 3f72d0a..0a597b1 100644 --- a/internal/service/organizer_reclassify_metadata_test.go +++ b/internal/service/organizer_reclassify_metadata_test.go @@ -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) } } diff --git a/internal/service/organizer_scrape_providers_test.go b/internal/service/organizer_scrape_providers_test.go index e20d785..ea0fb8d 100644 --- a/internal/service/organizer_scrape_providers_test.go +++ b/internal/service/organizer_scrape_providers_test.go @@ -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 } diff --git a/internal/service/playback_quality.go b/internal/service/playback_quality.go index 7cf3f6d..e47a7b7 100644 --- a/internal/service/playback_quality.go +++ b/internal/service/playback_quality.go @@ -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 { diff --git a/internal/service/playback_quality_test.go b/internal/service/playback_quality_test.go index cd42732..0622dba 100644 --- a/internal/service/playback_quality_test.go +++ b/internal/service/playback_quality_test.go @@ -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 diff --git a/internal/service/recognition_words_test.go b/internal/service/recognition_words_test.go index 04ffe40..3ef9ea0 100644 --- a/internal/service/recognition_words_test.go +++ b/internal/service/recognition_words_test.go @@ -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) } } diff --git a/internal/service/runtime_cache.go b/internal/service/runtime_cache.go index ddfd6cf..7885df6 100644 --- a/internal/service/runtime_cache.go +++ b/internal/service/runtime_cache.go @@ -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 { diff --git a/internal/service/runtime_cache_eviction_test.go b/internal/service/runtime_cache_eviction_test.go new file mode 100644 index 0000000..4a8a9c3 --- /dev/null +++ b/internal/service/runtime_cache_eviction_test.go @@ -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)) + } +} diff --git a/internal/service/sanitize_dotdir_test.go b/internal/service/sanitize_dotdir_test.go new file mode 100644 index 0000000..adface6 --- /dev/null +++ b/internal/service/sanitize_dotdir_test.go @@ -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*|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 +// `/.tmp-live-media/电影/...` were written to `/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") + } +} diff --git a/internal/service/scanner_incremental_test.go b/internal/service/scanner_incremental_test.go index 1f25c49..55e5105 100644 --- a/internal/service/scanner_incremental_test.go +++ b/internal/service/scanner_incremental_test.go @@ -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) } } diff --git a/internal/service/scanner_local_ingest.go b/internal/service/scanner_local_ingest.go index ca7be6f..b95700d 100644 --- a/internal/service/scanner_local_ingest.go +++ b/internal/service/scanner_local_ingest.go @@ -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 diff --git a/internal/service/scanner_local_metadata_test.go b/internal/service/scanner_local_metadata_test.go index 7bf6a28..d58e0a8 100644 --- a/internal/service/scanner_local_metadata_test.go +++ b/internal/service/scanner_local_metadata_test.go @@ -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) } } diff --git a/internal/service/scanner_probe_backfill_test.go b/internal/service/scanner_probe_backfill_test.go new file mode 100644 index 0000000..a11b940 --- /dev/null +++ b/internal/service/scanner_probe_backfill_test.go @@ -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") + } +} diff --git a/internal/service/scraper_query_case_test.go b/internal/service/scraper_query_case_test.go new file mode 100644 index 0000000..aad64ca --- /dev/null +++ b/internal/service/scraper_query_case_test.go @@ -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") + } +} diff --git a/internal/service/scraper_query_clean.go b/internal/service/scraper_query_clean.go index bc208ad..b7ea7fb 100644 --- a/internal/service/scraper_query_clean.go +++ b/internal/service/scraper_query_clean.go @@ -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 diff --git a/internal/service/scraper_query_clean_regression_test.go b/internal/service/scraper_query_clean_regression_test.go index 8939757..09decb4 100644 --- a/internal/service/scraper_query_clean_regression_test.go +++ b/internal/service/scraper_query_clean_regression_test.go @@ -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) { diff --git a/internal/service/scraper_query_movie_test.go b/internal/service/scraper_query_movie_test.go index d004667..5d8a3c4 100644 --- a/internal/service/scraper_query_movie_test.go +++ b/internal/service/scraper_query_movie_test.go @@ -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) } } diff --git a/internal/service/scraper_query_test.go b/internal/service/scraper_query_test.go index 188052d..6b42260 100644 --- a/internal/service/scraper_query_test.go +++ b/internal/service/scraper_query_test.go @@ -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 { diff --git a/internal/service/service_builder.go b/internal/service/service_builder.go index 73e571b..5e0c0f3 100644 --- a/internal/service/service_builder.go +++ b/internal/service/service_builder.go @@ -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 指向解析成可拉取的直链/本地路径。 diff --git a/internal/service/strm_service.go b/internal/service/strm_service.go index febf3f6..94054f2 100644 --- a/internal/service/strm_service.go +++ b/internal/service/strm_service.go @@ -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" } diff --git a/internal/service/test_db_test.go b/internal/service/test_db_test.go index f7e082f..10239be 100644 --- a/internal/service/test_db_test.go +++ b/internal/service/test_db_test.go @@ -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 } diff --git a/internal/service/transcoder.go b/internal/service/transcoder.go index a460020..4c0e7d6 100644 --- a/internal/service/transcoder.go +++ b/internal/service/transcoder.go @@ -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) diff --git a/internal/service/transcoder_availability_test.go b/internal/service/transcoder_availability_test.go new file mode 100644 index 0000000..fe89188 --- /dev/null +++ b/internal/service/transcoder_availability_test.go @@ -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) + } + } +} diff --git a/internal/service/walk.go b/internal/service/walk.go index a1b939b..05e3e1a 100644 --- a/internal/service/walk.go +++ b/internal/service/walk.go @@ -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()} diff --git a/internal/service/walk_dotdir_test.go b/internal/service/walk_dotdir_test.go new file mode 100644 index 0000000..9705c36 --- /dev/null +++ b/internal/service/walk_dotdir_test.go @@ -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) + } +} diff --git a/web/src/components/MediaCard.tsx b/web/src/components/MediaCard.tsx index 4790a16..4965d1a 100644 --- a/web/src/components/MediaCard.tsx +++ b/web/src/components/MediaCard.tsx @@ -82,14 +82,6 @@ export const MediaCard = memo(function MediaCard({ )} - {/* Episode count badge:统一放右下角(桌面端与移动端一致),与左上角评分彻底错开 */} - {(count !== undefined && count > 1) || (count === undefined && versionCount > 1) ? ( - - - {count !== undefined && count > 1 ? `${count} 集` : `${versionCount} 版本`} - - ) : null} - {/* Rating Badge */} {displayRating > 0 && ( @@ -137,13 +129,23 @@ export const MediaCard = memo(function MediaCard({ }`}> {media.title}

-
+
{media.year > 0 ? media.year : "未知年份"} - {media.video_codec && ( - - {media.video_codec} - - )} + + {/* 集数/版本数徽标:放在海报下方的信息行而不是压在画面上。海报底部通常 + 自带片名,角标会盖住它(Game of Thrones / Breaking Bad 都很明显)。 */} + {(count !== undefined && count > 1) || (count === undefined && versionCount > 1) ? ( + + + {count !== undefined && count > 1 ? `${count} 集` : `${versionCount} 版本`} + + ) : null} + {media.video_codec && ( + + {media.video_codec} + + )} +
diff --git a/web/src/pages/AdminUsersTable.tsx b/web/src/pages/AdminUsersTable.tsx index 914f88c..c7c91bd 100644 --- a/web/src/pages/AdminUsersTable.tsx +++ b/web/src/pages/AdminUsersTable.tsx @@ -46,13 +46,13 @@ export function AdminUsersTable({ - - - - + + + + - - + + @@ -72,8 +72,8 @@ export function AdminUsersTable({ )} - - + - - -
用户名角色媒体库权限状态用户名角色媒体库权限状态 权限说明最近登录操作最近登录操作
{u.role === 'admin' ? '管理员' : '观看用户'} + {u.role === 'admin' ? '管理员' : '观看用户'} {u.role === 'admin' ? ( 全库 (管理员) @@ -94,13 +94,13 @@ export function AdminUsersTable({ )} + {u.is_active ? '正常' : '已禁用'} + {u.role === 'admin' ? '全部管理权限' : '仅浏览/播放/外部播放器,无下载与文件操作'} + {u.last_login_at ? new Date(u.last_login_at).toLocaleString() : '从未登录'} {u.realtime_online && 在线} diff --git a/web/src/pages/DlnaPage.tsx b/web/src/pages/DlnaPage.tsx index 2df6c36..ce3b416 100644 --- a/web/src/pages/DlnaPage.tsx +++ b/web/src/pages/DlnaPage.tsx @@ -20,7 +20,7 @@ export function DlnaPage() { dlnaAPI .list(force) .then(setDevices) - .catch(() => toast.error('设备发现失败,容器网络可能不支持组播')) + .catch(() => toast.error('设备发现失败,容器网络可能不支持组播')) .finally(() => setScanning(false)) } @@ -94,7 +94,7 @@ export function DlnaPage() { {devices.length === 0 && !scanning && (

- 未发现任何 DLNA 设备。请确保:服务器与设备在同一局域网,容器使用 host 网络模式, + 未发现任何 DLNA 设备。请确保:服务器与设备在同一局域网,容器使用 host 网络模式, 目标设备已开启 DLNA / 屏幕镜像。

diff --git a/web/src/pages/PlayerPage.tsx b/web/src/pages/PlayerPage.tsx index 63f37ef..a58809a 100644 --- a/web/src/pages/PlayerPage.tsx +++ b/web/src/pages/PlayerPage.tsx @@ -1539,6 +1539,13 @@ export function PlayerPage() { setHlsSource('local') setCloudWaiting(false) setCloudWaitMessage('') + // 本地档位不可用(宿主机没有可用的 ffmpeg)时不要进入必然失败的 HLS: + // 保持直连播放并说明怎么修,避免播放器先打一个 500 再回退。 + if (!quality.available) { + setPlaybackMode('direct') + toast.error(quality.note || '当前无法进行 HLS 转码,请在「系统设置 → 常规」安装 ffmpeg 后重试') + return + } if (position > 2) setHlsStartSec(position) setPlaybackMode('hls') }, diff --git a/web/src/pages/ProfileManagementPage.tsx b/web/src/pages/ProfileManagementPage.tsx index bfb0ad4..dbff8b1 100644 --- a/web/src/pages/ProfileManagementPage.tsx +++ b/web/src/pages/ProfileManagementPage.tsx @@ -154,7 +154,7 @@ export function ProfileManagementPage() {
👤

暂无 Profile

-

点击右上角"创建 Profile"开始

+

点击右上角「创建 Profile」开始

)}