7.7 KiB
7.7 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(批准后开始实现时一并提交)。 - 全部代码改动 + 本地容器验证。