# Library & Organize 功能设计文档 ## 背景与目标 目前 MeTube 只有一个下载目录(`DOWNLOAD_DIR`),"文件夹" 只是其子目录。用户另有一个大型媒体库(数百个文件夹,按演员/主题组织),需要: 1. **整理(Organize)**:把下载目录中的文件移动进媒体库的某个文件夹;目标文件夹选择要支持"快速匹配"(搜索 + 自动建议)。 2. **浏览媒体库**:按文件夹导航、页内播放视频、可删除文件。媒体库在体验上类似"已完成",但数据源是磁盘上的大库,而非下载记录。 ### 已确认的关键决策 - 文件整理进媒体库后,**从"已完成"列表移除**(媒体库是它的唯一家)。 - 快速匹配 = **可搜索的文件夹选择器 + 基于标题/uploader 的自动建议**,两者都要。 - 播放走 **aiohttp 静态路由**(与下载目录一致,天然支持 Range 拖拽)。 ## 总体架构 ``` 下载区 (DOWNLOAD_DIR) 媒体库 (LIBRARY_DIR, 新 volume) ┌────────────────────┐ organize ┌──────────────────────────┐ │ 已完成条目 (shelve) │ ──────────→ │ 文件夹A/ 文件夹B/ ... │ │ 文件 + 元数据 │ 移动文件 │ 纯磁盘状态,无 DB │ └────────────────────┘ 删除 shelve └──────────────────────────┘ 条目 ↑ 浏览/播放/删除 (实时扫盘 + 短 TTL 缓存) ``` 媒体库**不建索引、不入库**:状态实时从磁盘读取,文件夹列表做 ~60s 内存缓存。库可能很大,但 `os.scandir` 列目录是毫秒级;只有"文件夹列表 + 文件数"需要遍历一次顶层目录,缓存即可。 ## 后端设计 (app/main.py 为主) ### 配置 - 新增 `LIBRARY_DIR`(默认空 = 功能关闭)、`PUBLIC_HOST_LIBRARY_URL`(默认 `library/`)。 - `docker-compose.yml`:挂载 `./library:/library`,设 `LIBRARY_DIR=/library`。 - 功能开关:`LIBRARY_DIR` 为空时,所有 `/library/*` API 返回 400,前端隐藏媒体库入口和"整理"按钮。configuration socket 事件已会把配置发给前端,UI 据此显隐。 ### API 一览 | 端点 | 方法 | 说明 | |---|---|---| | `/library/folders` | GET | 递归列出库内文件夹(相对路径)+ 每个文件夹的视频文件数。内存缓存 ~60s,organize/delete 后主动失效。排除 `CUSTOM_DIRS_EXCLUDE_REGEX` 匹配的隐藏目录 | | `/library/files?folder=` | GET | 列出某文件夹内的视频文件:文件名、大小、mtime;按名称排序。`folder` 为空 = 库根目录 | | `/library/organize` | POST | `{ids: [...], folder: ''}`。仅接受 **done** 条目:逐个解析文件路径 → 校验/创建目标文件夹(必须在 LIBRARY_DIR 内)→ `shutil.move`(executor 中执行,跨盘时为拷贝+删除,大文件可能较慢)→ 冲突时按现有约定追加 5 位随机后缀并改名 → 从 done 存储删除 → 发送 `cleared` socket 事件让前端移除条目。逐项收集成败,返回 `{status, moved: n, errors: [...]}` | | `/library/delete` | POST | `{paths: [...]}`(库内相对路径)。仅删文件、拒绝目录;删除后尝试清理变空的父目录(不删库根) | | `/library/thumbnail?path=` | GET | 复用现有 `_extract_video_frame`(ffprobe 定位 25% + ffmpeg 抽帧),缓存键用 `sha1('lib:'+relpath)`,与下载缩略图同目录但命名空间隔离。无本地文件概念之外的 fallback:失败返回占位 SVG | ### 静态播放路由 - `routes.static(config.URL_PREFIX + 'library/', config.LIBRARY_DIR)` — 注册在 `/library/*` 动态 API **之后**(aiohttp 先注册先匹配,现有静态路由本就在文件末尾)。 - 前端拼播放 URL:`library/` + `encodeURIComponent(folder + '/' + filename)`。 ### 路径安全 所有接受相对路径的端点统一走一个校验函数:`realpath(join(LIBRARY_DIR, rel))` 必须以 `realpath(LIBRARY_DIR)` 为前缀,否则 400。与现有 `__calc_download_path` 同款逻辑。 ### 自动建议匹配(前端实现) 文件夹列表本来就要发给前端,因此匹配放客户端,零额外请求: - 输入:待整理条目的 `title` 与 `entry.uploader`(uploader 通常就是演员名,是最强信号)。 - 归一化:小写、去非字母数字、按空格分词。 - 打分:uploader 与文件夹名完全相等(归一化后)= 最高分;子串包含次之;token 交集比例再次。取 Top 3 作为建议。 ## 前端设计 (ui/src/app) ### 导航模式 - `AppComponent` 增加 `mode: 'downloads' | 'library'`(持久化到 preferences)。 - 侧边栏"状态"区下方新增"媒体库"入口(带视频总数);点击切到 library 模式,此时"文件夹"区改列**媒体库文件夹**(数据来自 `/library/folders`,复用现有 nav-item 样式与计数)。 - 主区域:library 模式渲染新的 `LibraryBrowserComponent`,否则渲染现有的 active-downloads + download-list。 ### LibraryBrowserComponent(新) - 工具条:搜索框(按文件名过滤)、排序(名称/时间/大小)、视图切换(列表/网格)——均复用现有模式。 - 条目卡片/行:缩略图(`/library/thumbnail?path=`)、文件名、大小、mtime;操作:**播放**(复用现有页内播放器 modal,URL 指向静态路由)、**删除**(confirm 后调 `/library/delete`)。 - 数据量:单文件夹内文件数通常有限(几十~几百),无需虚拟滚动;若后续需要可加 cdk-virtual-scroll。 ### 整理流程(已完成视图) - 批量操作条新增"**整理到媒体库…**"按钮(在"移动到…"旁),选中条目后点击打开整理面板: 1. **建议文件夹**(Top 3,客户端匹配,一键选择) 2. **搜索框**:输入即过滤全部库文件夹(ng-select 或自绘列表,复用样式) 3. **新建文件夹**:输入名创建(多层如 `A/B` 允许) - 确认 → `POST /library/organize` → 成功条目从"已完成"消失(`cleared` 事件驱动)+ toast 提示移动数量;部分失败时 toast 列出失败原因。 - 单条整理:已完成行/卡片的操作区加"整理"图标,打开同一面板(预选该条目)。 ## 边界与异常 - `LIBRARY_DIR` 未配置:API 400 + UI 隐藏入口。 - 目标/待删路径逃逸库根(`../`):400。 - 整理 pending/downloading 条目:跳过并计入 errors(只有已完成文件可整理)。 - 目标已存在同名文件:追加 `_XXXXX` 后缀(与下载区 move 行为一致),条目不丢失。 - 跨文件系统移动大文件:executor 中执行,前端给 organizing 状态;失败保留原文件与条目。 - 库文件被外部改动:文件夹缓存 60s 过期后自动反映;播放/删除前都实时校验路径存在。 ## 实施步骤 1. 后端:`Config` 增加 `LIBRARY_DIR` / `PUBLIC_HOST_LIBRARY_URL`;路径校验辅助函数。 2. 后端:`/library/folders`、`/library/files`(含缓存)。 3. 后端:`/library/organize`(复用 move/conflict 逻辑 + done 条目删除 + cleared 事件)。 4. 后端:`/library/delete`、`/library/thumbnail`、静态路由。 5. 前端:`downloads.service.ts` 增加 library 相关方法(或直接 HttpClient)。 6. 前端:侧边栏模式切换 + 媒体库文件夹列表。 7. 前端:`LibraryBrowserComponent`(列表/网格、播放、删除)。 8. 前端:整理面板(建议 + 搜索 + 新建),接入批量条与行操作。 9. 样式与中文文案,与现有设计语言对齐。 10. 验证:curl 端到端(建库目录→整理→文件移动+条目消失→播放 URL 200 且支持 Range→删除生效);UI build;重启持久性检查。 11. 文档:README 与 AGENTS.md 提及新环境变量;docker-compose.yml 增加挂载示例。 ## 交付物 - 本文档保存为仓库根的 `LIBRARY_ORGANIZE_DESIGN.md`(批准后开始实现时一并提交)。 - 全部代码改动 + 本地容器验证。 ## 多根目录支持(多文件夹挂载) > 以下为在既有单根设计基础上的扩展,不改变既有部分;实现时与上一节内容合并生效。 ### 目标 - 媒体库在 UI 上**仍只有一个入口**(侧边栏"媒体库"nav 项),不按根目录拆分多个入口。 - Docker 可以挂载多个文件夹(多个独立目录/卷),它们都属于同一个媒体库范围。 - 侧边栏按根目录**分组展示**:每个 root 一个可折叠的顶级分组,组内保持现有扁平列表(显示根内相对路径);不引入多级树形控件。 ### 配置 - 新增 `LIBRARY_DIRS`(逗号分隔的容器内绝对路径列表),默认 `%%LIBRARY_DIR`(沿用 Config 的 `%%` 插值)。 - 只设置 `LIBRARY_DIR` 时行为完全不变(单根、`''` = 库根)。 - 解析后列表为空 → 功能关闭:API 400 + UI 隐藏入口。 - 每个根目录有一个 **label**(默认取容器内路径的 basename,如 `/media/actor-a` → `actor-a`): - 必须匹配 `[A-Za-z0-9_-]+`;启动时校验,非法 label 直接报错。 - label 重复时按配置顺序追加 `-2`、`-3`(确定性去重)。 - 与 API 保留词(`folders`、`files`、`organize`、`delete`、`move`、`thumbnail`)冲突时同样加后缀,避免与动态端点/静态前缀混淆。 docker-compose 示例: ```yaml volumes: - /mnt/actor-a:/media/actor-a - /mnt/actor-b:/media/actor-b environment: - LIBRARY_DIRS=/media/actor-a,/media/actor-b ``` ### 文件夹命名空间(分组展示) - 每个文件夹条目含三个字段:`label`(所属 root)、`name`(根内相对路径,`''` 即该 root 顶级)、`path`(完整库内路径 = `label/name`,供所有 API 使用)。label 仅存在于 API 路径,不重复显示在文件夹名上。 - 侧边栏"媒体库文件夹"列表:每个 root 一个**分组头**(label + 该 root 视频总数 + 折叠箭头),点击箭头展开/折叠,点击分组头本身 = 选中该 root 顶级(`path='