12 KiB
12 KiB
Library & Organize 功能设计文档
背景与目标
目前 MeTube 只有一个下载目录(DOWNLOAD_DIR),"文件夹" 只是其子目录。用户另有一个大型媒体库(数百个文件夹,按演员/主题组织),需要:
- 整理(Organize):把下载目录中的文件移动进媒体库的某个文件夹;目标文件夹选择要支持"快速匹配"(搜索 + 自动建议)。
- 浏览媒体库:按文件夹导航、页内播放视频、可删除文件。媒体库在体验上类似"已完成",但数据源是磁盘上的大库,而非下载记录。
已确认的关键决策
- 文件整理进媒体库后,从"已完成"列表移除(媒体库是它的唯一家)。
- 快速匹配 = 可搜索的文件夹选择器 + 基于标题/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=<rel> |
GET | 列出某文件夹内的视频文件:文件名、大小、mtime;按名称排序。folder 为空 = 库根目录 |
/library/organize |
POST | {ids: [...], folder: '<rel>'}。仅接受 done 条目:逐个解析文件路径 → 校验/创建目标文件夹(必须在 LIBRARY_DIR 内)→ shutil.move(executor 中执行,跨盘时为拷贝+删除,大文件可能较慢)→ 冲突时按现有约定追加 5 位随机后缀并改名 → 从 done 存储删除 → 发送 cleared socket 事件让前端移除条目。逐项收集成败,返回 {status, moved: n, errors: [...]} |
/library/delete |
POST | {paths: [...]}(库内相对路径)。仅删文件、拒绝目录;删除后尝试清理变空的父目录(不删库根) |
/library/thumbnail?path=<rel> |
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。
整理流程(已完成视图)
- 批量操作条新增"整理到媒体库…"按钮(在"移动到…"旁),选中条目后点击打开整理面板:
- 建议文件夹(Top 3,客户端匹配,一键选择)
- 搜索框:输入即过滤全部库文件夹(ng-select 或自绘列表,复用样式)
- 新建文件夹:输入名创建(多层如
A/B允许)
- 确认 →
POST /library/organize→ 成功条目从"已完成"消失(cleared事件驱动)+ toast 提示移动数量;部分失败时 toast 列出失败原因。 - 单条整理:已完成行/卡片的操作区加"整理"图标,打开同一面板(预选该条目)。
边界与异常
LIBRARY_DIR未配置:API 400 + UI 隐藏入口。- 目标/待删路径逃逸库根(
../):400。 - 整理 pending/downloading 条目:跳过并计入 errors(只有已完成文件可整理)。
- 目标已存在同名文件:追加
_XXXXX后缀(与下载区 move 行为一致),条目不丢失。 - 跨文件系统移动大文件:executor 中执行,前端给 organizing 状态;失败保留原文件与条目。
- 库文件被外部改动:文件夹缓存 60s 过期后自动反映;播放/删除前都实时校验路径存在。
实施步骤
- 后端:
Config增加LIBRARY_DIR/PUBLIC_HOST_LIBRARY_URL;路径校验辅助函数。 - 后端:
/library/folders、/library/files(含缓存)。 - 后端:
/library/organize(复用 move/conflict 逻辑 + done 条目删除 + cleared 事件)。 - 后端:
/library/delete、/library/thumbnail、静态路由。 - 前端:
downloads.service.ts增加 library 相关方法(或直接 HttpClient)。 - 前端:侧边栏模式切换 + 媒体库文件夹列表。
- 前端:
LibraryBrowserComponent(列表/网格、播放、删除)。 - 前端:整理面板(建议 + 搜索 + 新建),接入批量条与行操作。
- 样式与中文文案,与现有设计语言对齐。
- 验证:curl 端到端(建库目录→整理→文件移动+条目消失→播放 URL 200 且支持 Range→删除生效);UI build;重启持久性检查。
- 文档: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 示例:
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='<label>')。组内一行一个文件夹,显示name(如Sub/Deeper,不再带 label 前缀),按名称排序,支持筛选。 - 分组折叠状态为组件内存态(默认展开),不持久化。
- 单根模式:保留
''= 库根(现状,向后兼容),不显示分组头,"根目录"按钮照常显示,行为与现状完全一致。 - 多根模式:不返回
'';每个 label 即一个分组,侧边栏"根目录"按钮隐藏。
API 调整
/library/folders:依次扫描每个 root,返回扁平列表[{label, name, path, count}](先按 label 排序,组内按 name 排序);path由后端拼接,客户端直接使用。/library/files?folder=label/rel:第一段解析为 root label,其余部分在该 root 内按现有 realpath 前缀校验解析。新增每条文件的path字段(库内完整相对路径,含 label);name仅用于展示,客户端不再自己拼接folder + '/' + name。/library/organize、/library/move:folder第一段必须匹配已配置的 root label;多根模式下folder=''直接拒绝。整理面板带 root 上下文:建议文件夹跨所有 root 给出(条目带 label),新建文件夹先选目标 root、再输入组内名称,客户端拼成label/name提交。路径校验逻辑与现有_resolve_library_path一致。/library/delete:paths同样按 label 解析;空目录向上清理只到该 root 边界,不删除 root 本身。/library/thumbnail:缓存 key 已包含 rel(sha1('lib:' + rel)),label 内嵌后各 root 天然隔离,无冲突。
静态播放路由
- 每个 root 注册一条静态路由:
library/<label>/→ 该 root(动态/library/*端点先注册、先匹配优先,无冲突)。 PUBLIC_HOST_LIBRARY_URL前缀不变;前端播放 URL =library/+label/rel逐段encodeURIComponent。
边界与异常(多根部分)
- 某个 root 目录不存在:启动告警并跳过;全部缺失 = 功能关闭。
- root 互相嵌套(一个包含另一个):启动告警,要求根目录保持不相交,避免文件夹重复出现在列表中。
- 跨 root 移动(
/library/move):shutil.move跨文件系统可用,大文件仍在 executor 中执行。 CUSTOM_DIRS_EXCLUDE_REGEX对每个 root 各自生效。
UI 影响
- 媒体库入口不变(仍是一个"媒体库"nav 项)。
libraryEnabled改为!!config['LIBRARY_DIRS'](configuration socket 序列化整个 Config,LIBRARY_DIRS自动下发)。- 侧边栏:多根模式渲染分组头 + 组内文件夹(组内显示
name);单根模式渲染"根目录"按钮 + 扁平列表(现状)。 - 所有文件操作路径统一使用服务端返回的
file.path,不再前端拼接。
实施与版本
- 实现时按 AGENTS.md 版本规则 bump minor,并在同一提交更新 DEPLOY.md、README 与 docker-compose.yml 示例。