feat: 支持媒体库多根目录挂载与分组展示

This commit is contained in:
tigerenwork 2026-08-16 18:46:07 +08:00
parent 3d2d53b307
commit 96fbd6d13e
15 changed files with 446 additions and 80 deletions

View File

@ -47,8 +47,8 @@ guide to switching back to the official build once the upstream fix lands.
./build-ytdlp.sh
docker buildx build --platform linux/amd64 \
--build-arg VERSION=1.27 \
-t 192.168.2.212:3000/tigeren/metube:1.27 \
--build-arg VERSION=1.29 \
-t 192.168.2.212:3000/tigeren/metube:1.29 \
--push .
```

View File

@ -112,3 +112,72 @@
- 本文档保存为仓库根的 `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='<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 示例。

View File

@ -57,6 +57,7 @@ Certain values can be set via environment variables, using the `-e` parameter on
* Set this to an SSD or RAM filesystem (e.g., `tmpfs`) for better performance.
* __Note__: Using a RAM filesystem may prevent downloads from being resumed.
* __LIBRARY_DIR__: Path to a separate media library directory (mounted into the container, e.g. `/library`). When set, the UI gains a **媒体库 (Library)** view — browse library folders, play videos in-page, delete files — and completed downloads can be **organized** (moved) into library folders, with folder search and title/uploader-based suggestions. Organized items are removed from the Completed list. Leave empty to disable the feature. Defaults to empty.
* __LIBRARY_DIRS__: Comma-separated list of library directories when multiple mounted folders should share a single library view, e.g. `LIBRARY_DIRS=/media/actor-a,/media/actor-b`. Each directory becomes a top-level collapsible group in the Library sidebar; folder names inside each group keep their group-relative path. Defaults to the value of `LIBRARY_DIR`; if both are set, `LIBRARY_DIRS` takes precedence and `LIBRARY_DIR` is ignored.
* __PUBLIC_HOST_LIBRARY_URL__: URL prefix under which library files are served. Defaults to `library/`.
### 📝 File Naming & yt-dlp

View File

@ -22,7 +22,10 @@ import aiohttp
from urllib.parse import urlparse
from watchfiles import DefaultFilter, Change, awatch
from ytdl import DownloadQueueNotifier, DownloadQueue, library_meta_path
from ytdl import (
DownloadQueueNotifier, DownloadQueue, library_meta_path,
build_library_roots, normalize_library_roots, resolve_library_path,
)
from yt_dlp.version import __version__ as yt_dlp_version
log = logging.getLogger('main')
@ -39,6 +42,7 @@ class Config:
'DELETE_FILE_ON_TRASHCAN': 'true',
'STATE_DIR': '.',
'LIBRARY_DIR': '',
'LIBRARY_DIRS': '%%LIBRARY_DIR',
'PUBLIC_HOST_LIBRARY_URL': 'library/',
'URL_PREFIX': '',
'PUBLIC_HOST_URL': 'download/',
@ -80,6 +84,18 @@ class Config:
sys.exit(1)
setattr(self, k, v in ('true', 'True', 'on', '1'))
# Media library: comma-separated container paths; empty = feature off.
# Kept as a list so the UI can check `LIBRARY_DIRS` directly.
self.LIBRARY_DIRS = [d.strip() for d in str(self.LIBRARY_DIRS).split(',') if d.strip()]
try:
roots = build_library_roots(self.LIBRARY_DIRS)
except ValueError as e:
log.error(str(e))
sys.exit(1)
self.LIBRARY_ROOTS = normalize_library_roots(roots)
if self.LIBRARY_DIRS and not self.LIBRARY_ROOTS:
log.warning('Library feature disabled: none of LIBRARY_DIRS exists')
if not self.URL_PREFIX.endswith('/'):
self.URL_PREFIX += '/'
@ -367,21 +383,14 @@ VIDEO_EXTENSIONS = ('.mp4', '.mkv', '.webm', '.avi', '.mov', '.m4v', '.ts', '.fl
def _library_root():
"""Realpath of the configured library directory, or None when the feature is off."""
if not config.LIBRARY_DIR:
return None
return os.path.realpath(config.LIBRARY_DIR)
"""Normalized [(label, realpath)] library roots; empty = feature off."""
return config.LIBRARY_ROOTS
def _resolve_library_path(rel):
"""Resolve a client-supplied library-relative path. Returns (root, path);
path is None when it would escape the library root."""
root = _library_root()
if root is None:
return None, None
path = os.path.realpath(os.path.join(root, rel or ''))
if path != root and not path.startswith(root + os.sep):
return root, None
path is None when it would escape the library root(s)."""
_, root, path = resolve_library_path(config.LIBRARY_ROOTS, rel or '')
return root, path
@ -389,16 +398,19 @@ _library_folders_cache = {'time': 0.0, 'data': None}
_LIBRARY_FOLDERS_TTL = 60
def _scan_library_folders(root):
def _scan_library_folders(roots):
exclude = re.compile(config.CUSTOM_DIRS_EXCLUDE_REGEX) if config.CUSTOM_DIRS_EXCLUDE_REGEX else None
folders = []
for dirpath, dirnames, filenames in os.walk(root):
rel = os.path.relpath(dirpath, root)
dirnames[:] = [d for d in dirnames
if not (exclude and exclude.search(os.path.normpath(os.path.join(rel, d))))]
count = sum(1 for f in filenames if os.path.splitext(f)[1].lower() in VIDEO_EXTENSIONS)
folders.append({'name': '' if rel == '.' else rel, 'count': count})
folders.sort(key=lambda f: f['name'].lower())
for label, root in roots:
for dirpath, dirnames, filenames in os.walk(root):
rel = os.path.relpath(dirpath, root)
dirnames[:] = [d for d in dirnames
if not (exclude and exclude.search(os.path.normpath(os.path.join(rel, d))))]
count = sum(1 for f in filenames if os.path.splitext(f)[1].lower() in VIDEO_EXTENSIONS)
name = '' if rel == '.' else rel
path = name if not label else (label + '/' + name if name else label)
folders.append({'label': label, 'name': name, 'path': path, 'count': count})
folders.sort(key=lambda f: (f['label'], f['name'].lower()))
return folders
@ -408,21 +420,22 @@ def _invalidate_library_folders():
@routes.get(config.URL_PREFIX + 'library/folders')
async def library_folders(request):
root = _library_root()
if root is None:
raise web.HTTPBadRequest(text='LIBRARY_DIR is not configured')
roots = _library_root()
if not roots:
raise web.HTTPBadRequest(text='LIBRARY_DIRS is not configured')
now = time.time()
if _library_folders_cache['data'] is None or now - _library_folders_cache['time'] > _LIBRARY_FOLDERS_TTL:
_library_folders_cache['data'] = await asyncio.get_running_loop().run_in_executor(None, _scan_library_folders, root)
_library_folders_cache['data'] = await asyncio.get_running_loop().run_in_executor(None, _scan_library_folders, roots)
_library_folders_cache['time'] = now
return web.Response(text=serializer.encode({'folders': _library_folders_cache['data']}))
@routes.get(config.URL_PREFIX + 'library/files')
async def library_files(request):
root, path = _resolve_library_path(request.query.get('folder', ''))
folder = request.query.get('folder', '')
root, path = _resolve_library_path(folder)
if root is None:
raise web.HTTPBadRequest(text='LIBRARY_DIR is not configured')
raise web.HTTPBadRequest(text='LIBRARY_DIRS is not configured')
if path is None or not os.path.isdir(path):
raise web.HTTPBadRequest(text='Invalid library folder')
@ -435,7 +448,12 @@ async def library_files(request):
if os.path.splitext(e.name)[1].lower() not in VIDEO_EXTENSIONS:
continue
st = e.stat()
entry = {'name': e.name, 'size': st.st_size, 'mtime': st.st_mtime}
entry = {
'name': e.name,
'path': folder + '/' + e.name if folder else e.name,
'size': st.st_size,
'mtime': st.st_mtime,
}
meta_file = library_meta_path(e.path)
if os.path.isfile(meta_file):
try:
@ -472,13 +490,12 @@ async def library_delete(request):
paths = post.get('paths')
if not paths:
raise web.HTTPBadRequest()
root = _library_root()
if root is None:
raise web.HTTPBadRequest(text='LIBRARY_DIR is not configured')
if not _library_root():
raise web.HTTPBadRequest(text='LIBRARY_DIRS is not configured')
deleted = 0
errors = []
for rel in paths:
_, path = _resolve_library_path(rel)
root, path = _resolve_library_path(rel)
if path is None or not os.path.isfile(path):
errors.append(f'{rel}: invalid path')
continue
@ -513,11 +530,11 @@ async def library_move(request):
folder = post.get('folder', '')
if not paths:
raise web.HTTPBadRequest()
root = _library_root()
if root is None:
raise web.HTTPBadRequest(text='LIBRARY_DIR is not configured')
target_dir = os.path.realpath(os.path.join(root, folder or ''))
if target_dir != root and not target_dir.startswith(root + os.sep):
roots = _library_root()
if not roots:
raise web.HTTPBadRequest(text='LIBRARY_DIRS is not configured')
_, _, target_dir = resolve_library_path(roots, folder or '')
if target_dir is None:
raise web.HTTPBadRequest(text='Invalid library folder')
def _move():
@ -525,7 +542,7 @@ async def library_move(request):
moved = 0
errors = []
for rel in paths:
_, src = _resolve_library_path(rel)
root, src = _resolve_library_path(rel)
if src is None or not os.path.isfile(src):
errors.append(f'{rel}: invalid path')
continue
@ -740,7 +757,8 @@ async def history(request):
async def connect(sid, environ):
log.info(f"Client connected: {sid}")
await sio.emit('all', serializer.encode(dqueue.get()), to=sid)
await sio.emit('configuration', serializer.encode(config), to=sid)
config_payload = {k: v for k, v in config.__dict__.items() if k != 'LIBRARY_ROOTS'}
await sio.emit('configuration', serializer.encode(config_payload), to=sid)
if config.CUSTOM_DIRS:
await sio.emit('custom_dirs', serializer.encode(get_custom_dirs()), to=sid)
if config.YTDL_OPTIONS_FILE:
@ -827,8 +845,14 @@ if config.URL_PREFIX != '/':
def index_redirect_dir(request):
return web.HTTPFound(config.URL_PREFIX)
if config.LIBRARY_DIR:
routes.static(config.URL_PREFIX + 'library/', config.LIBRARY_DIR, show_index=False)
if config.LIBRARY_ROOTS:
if len(config.LIBRARY_ROOTS) == 1:
# Legacy single-root mode: library files are served from 'library/'
routes.static(config.URL_PREFIX + 'library/', config.LIBRARY_ROOTS[0][1], show_index=False)
else:
# Multi-root mode: each root gets its own mount at 'library/<label>/'
for label, root in config.LIBRARY_ROOTS:
routes.static(config.URL_PREFIX + 'library/' + label + '/', root, show_index=False)
routes.static(config.URL_PREFIX + 'download/', config.DOWNLOAD_DIR, show_index=config.DOWNLOAD_DIRS_INDEXABLE)
routes.static(config.URL_PREFIX + 'audio_download/', config.AUDIO_DOWNLOAD_DIR, show_index=config.DOWNLOAD_DIRS_INDEXABLE)
routes.static(config.URL_PREFIX, os.path.join(config.BASE_DIR, 'ui/dist/metube/browser'))

View File

@ -27,6 +27,84 @@ def library_meta_path(video_path):
'Foo.mp4' -> '.Foo.mp4.metube.json' in the same directory."""
return os.path.join(os.path.dirname(video_path), '.' + os.path.basename(video_path) + '.metube.json')
# ---------- Multi-root media library helpers ----------
LIBRARY_LABEL_RE = re.compile(r'^[A-Za-z0-9_-]+$')
# Labels colliding with the dynamic /library/* endpoints would shadow their routes
LIBRARY_RESERVED_LABELS = {'folders', 'files', 'organize', 'delete', 'move', 'thumbnail'}
def build_library_roots(library_dirs):
"""Turn LIBRARY_DIRS (list of absolute container paths) into [(label, realpath), ...].
The label defaults to the directory basename; duplicates and reserved names
get a deterministic '-2', '-3' suffix. Missing directories are skipped with
a warning — an empty result disables the library feature."""
roots = []
used = set()
for raw in library_dirs:
directory = raw.strip()
if not directory:
continue
real = os.path.realpath(directory)
base = os.path.basename(real.rstrip(os.sep)) or os.path.basename(directory)
if not LIBRARY_LABEL_RE.match(base):
raise ValueError(
f'Invalid library root label "{base}" derived from "{directory}"; '
'labels must match [A-Za-z0-9_-]+')
if not os.path.isdir(real):
log.warning(f'Library root "{directory}" does not exist or is not a directory; skipping it')
continue
label = base if base not in LIBRARY_RESERVED_LABELS else f'{base}-2'
n = 2
while label in used:
n += 1
label = f'{base}-{n}'
used.add(label)
roots.append((label, real))
for i, (_, root_i) in enumerate(roots):
for _, root_j in roots[:i]:
if root_i != root_j and (root_i.startswith(root_j + os.sep) or root_j.startswith(root_i + os.sep)):
log.warning(
f'Library roots overlap: "{root_i}" and "{root_j}" are nested; '
'folders may appear duplicated in the Library view')
return roots
def normalize_library_roots(roots):
"""Single-root mode keeps the legacy '' label so API paths/URLs stay unchanged."""
if len(roots) == 1:
return [('', roots[0][1])]
return roots
def resolve_library_path(roots, rel):
"""Resolve a library-relative path (may carry a root label prefix in multi-root mode).
Returns (label, root, abs_path), or (None, None, None) when the feature is
off or the path escapes its root. In single-root mode rel is label-less and
'' means the library root itself."""
if not roots:
return None, None, None
if len(roots) == 1:
label, root = roots[0]
path = os.path.realpath(os.path.join(root, rel or ''))
if path != root and not path.startswith(root + os.sep):
return None, None, None
return label, root, path
if not rel:
return None, None, None
label, sep, remainder = rel.partition('/')
root = next((r for l, r in roots if l == label), None)
if root is None:
return None, None, None
path = os.path.realpath(os.path.join(root, remainder))
if path != root and not path.startswith(root + os.sep):
return None, None, None
return label, root, path
class DownloadQueueNotifier:
async def added(self, dl):
raise NotImplementedError
@ -983,12 +1061,12 @@ class DownloadQueue:
async def organize_to_library(self, ids, folder):
"""Move completed downloads' files into the media library and remove
their done entries — the library becomes the file's only home."""
if not self.config.LIBRARY_DIR:
return {'status': 'error', 'msg': 'LIBRARY_DIR is not configured', 'moved': 0, 'errors': []}
library_root = os.path.realpath(self.config.LIBRARY_DIR)
target_dir = os.path.realpath(os.path.join(library_root, folder or ''))
if target_dir != library_root and not target_dir.startswith(library_root + os.sep):
return {'status': 'error', 'msg': f'Folder "{folder}" must resolve inside the library directory', 'moved': 0, 'errors': []}
roots = self.config.LIBRARY_ROOTS
if not roots:
return {'status': 'error', 'msg': 'LIBRARY_DIRS is not configured', 'moved': 0, 'errors': []}
_, _, target_dir = resolve_library_path(roots, folder or '')
if target_dir is None:
return {'status': 'error', 'msg': f'Folder "{folder}" must resolve inside the library directories', 'moved': 0, 'errors': []}
os.makedirs(target_dir, exist_ok=True)
moved = 0
errors = []

View File

@ -11,10 +11,14 @@ services:
volumes:
- ./downloads:/downloads
- ./metube-config:/config
# Media library for the organize/browse feature (change the host path to your real library)
- ./library:/library
# Optional: mount cookies file for authenticated downloads
# - ./cookies:/cookies:ro
# Media library for the organize/browse feature (change the host path to your real library)
- ./library:/library
# Multiple library mounts share one Library view; each mount becomes a
# top-level collapsible group. Uncomment to use instead of ./library:
# - /mnt/media-a:/media/media-a
# - /mnt/media-b:/media/media-b
# Optional: mount cookies file for authenticated downloads
# - ./cookies:/cookies:ro
environment:
# Basic configuration
- UID=0
@ -25,8 +29,10 @@ services:
- DOWNLOAD_DIR=/downloads
- STATE_DIR=/config
- TEMP_DIR=/downloads
# Media library (organize/browse feature); leave empty to disable
- LIBRARY_DIR=/library
# Media library (organize/browse feature); leave empty to disable
- LIBRARY_DIR=/library
# Multi-root alternative (overrides LIBRARY_DIR, comma-separated):
# - LIBRARY_DIRS=/media/media-a,/media/media-b
# Download behavior
- DOWNLOAD_MODE=limited
@ -111,4 +117,4 @@ services:
# metube-downloads:
# driver: local
# metube-cookies:
# driver: local
# driver: local

View File

@ -95,6 +95,7 @@
[files]="libraryFiles"
[folderPath]="libraryFolder"
[folders]="libraryFolderNames"
[multiRoot]="libraryMultiRoot"
[loading]="libraryLoading"
(play)="playLibraryFile($event)"
(del)="deleteLibraryFile($event)"
@ -160,6 +161,12 @@
</div>
<div class="modal-body">
<p>将 {{ organizeItems.length }} 个文件移动到媒体库文件夹:</p>
<div *ngIf="libraryMultiRoot" class="mb-2">
<label class="text-muted small mb-1 d-block">目标根目录</label>
<select class="form-select form-select-sm" [(ngModel)]="organizeRoot" [disabled]="organizeInProgress">
<option *ngFor="let r of libraryRootLabels" [value]="r">{{ r }}</option>
</select>
</div>
<div *ngIf="organizeSuggestions.length > 0" class="mb-3">
<div class="text-muted small mb-2">建议(点击直接整理):</div>
<button type="button" class="btn btn-outline-secondary btn-sm me-2" *ngFor="let s of organizeSuggestions" (click)="confirmOrganize(s)" [disabled]="organizeInProgress">{{ s }}</button>

View File

@ -57,6 +57,7 @@ export class AppComponent implements OnInit, OnDestroy {
libraryFiles: LibraryFile[] = [];
libraryFolder = ''; // '' = library root
libraryLoading = false;
organizeRoot = '';
// Organize-to-library modal
organizeOpen = false;
@ -213,9 +214,16 @@ export class AppComponent implements OnInit, OnDestroy {
this.downloads.libraryFolders().subscribe({
next: (res) => {
this.libraryFolders = res.folders;
this.libraryFolderNames = res.folders.map(f => f.name).filter(n => n);
this.libraryFolderNames = res.folders.map(f => f.path).filter(p => p);
if (!this.libraryRootLabels.includes(this.organizeRoot)) {
this.organizeRoot = this.libraryRootLabels[0] || '';
}
},
error: () => { this.libraryFolders = []; this.libraryFolderNames = []; }
error: () => {
this.libraryFolders = [];
this.libraryFolderNames = [];
this.organizeRoot = '';
}
});
}
@ -237,8 +245,16 @@ export class AppComponent implements OnInit, OnDestroy {
return this.libraryFolders.reduce((total, f) => total + f.count, 0);
}
get libraryRootLabels(): string[] {
return Array.from(new Set(this.libraryFolders.map(f => f.label).filter(l => l)));
}
get libraryMultiRoot(): boolean {
return this.libraryRootLabels.length > 1;
}
libraryFilePath(file: LibraryFile): string {
return this.libraryFolder ? this.libraryFolder + '/' + file.name : file.name;
return file.path || (this.libraryFolder ? this.libraryFolder + '/' + file.name : file.name);
}
playLibraryFile(file: LibraryFile): void {
@ -300,6 +316,7 @@ export class AppComponent implements OnInit, OnDestroy {
this.organizeItems = items;
this.organizeTarget = null;
this.organizeInProgress = false;
this.organizeRoot = this.libraryRootLabels[0] || '';
this.organizeOpen = true;
if (this.libraryFolders.length === 0) this.loadLibraryFolders();
}
@ -328,17 +345,22 @@ export class AppComponent implements OnInit, OnDestroy {
else if (uploader && (folderNorm.includes(uploader) || uploader.includes(folderNorm))) score += 50;
const overlap = folderNorm.split(' ').filter(t => t.length > 2 && titleTokens.has(t)).length;
score += overlap * 10;
if (score > 0) scores.set(f.name, (scores.get(f.name) || 0) + score);
if (score > 0) scores.set(f.path, (scores.get(f.path) || 0) + score);
}
}
return Array.from(scores.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 3)
.map(([name]) => name);
.map(([path]) => path);
}
confirmOrganize(folder: string | null): void {
if (folder === null || this.organizeInProgress) return;
// Multi-root mode: a bare new-folder name (ng-select addTag) is created
// under the root picked in the modal; known paths are used as-is.
if (this.libraryMultiRoot && folder && !folder.includes('/') && !this.libraryFolderNames.includes(folder)) {
folder = this.organizeRoot + '/' + folder;
}
const ids = this.organizeItems.map(i => i.key);
this.organizeInProgress = true;
this.downloads.libraryOrganize(ids, folder).subscribe((res) => {
@ -755,7 +777,8 @@ export class AppComponent implements OnInit, OnDestroy {
const limit = config['DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT'];
if (limit !== '0') this.playlistItemLimit = limit;
const wasEnabled = this.libraryEnabled;
this.libraryEnabled = !!config['LIBRARY_DIR'];
const dirs = config['LIBRARY_DIRS'];
this.libraryEnabled = Array.isArray(dirs) ? dirs.length > 0 : !!dirs;
if (!this.libraryEnabled && this.mode === 'library') this.mode = 'downloads';
if (this.libraryEnabled && !wasEnabled && this.libraryFolders.length === 0) this.loadLibraryFolders();
}

View File

@ -10,7 +10,9 @@ export interface Status {
}
export interface LibraryFolder {
label: string; // root label ('' in legacy single-root mode)
name: string;
path: string; // full library-relative path used by all library APIs
count: number;
}
@ -23,6 +25,7 @@ export interface LibraryFileMeta {
}
export interface LibraryFile {
path: string;
name: string;
size: number;
mtime: number;

View File

@ -102,12 +102,14 @@
<div class="batchbar" [class.show]="selected.size > 0">
<span class="batch-count">已选 {{ selected.size }} 项</span>
<div class="move-wrap">
<button type="button" class="batch-btn" (click)="moveMenuOpen = !moveMenuOpen">
<button type="button" class="batch-btn" (click)="toggleMoveMenu()">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
移动到…
</button>
<div class="move-menu" *ngIf="moveMenuOpen">
<input type="text" class="move-search" placeholder="搜索文件夹…" [(ngModel)]="moveQuery" autofocus>
<button *ngFor="let opt of folderOptions()" type="button" class="move-item" (click)="moveTo(opt.path)">{{ opt.label }}</button>
<button *ngIf="!folderOptions().length" type="button" class="move-item" disabled>无匹配文件夹</button>
<div class="move-new">
<input type="text" placeholder="新建文件夹…" [(ngModel)]="newFolder" (keydown.enter)="confirmNewFolder()">
<button type="button" class="batch-btn small" (click)="confirmNewFolder()">创建</button>

View File

@ -450,6 +450,7 @@
.move-item
width: 100%
flex-shrink: 0
border: none
background: none
border-radius: 6px
@ -467,8 +468,23 @@
&:disabled
color: var(--muted)
.move-search
flex-shrink: 0
border: 1px solid var(--border)
background: var(--bg)
border-radius: 6px
padding: 5px 8px
font-size: 12.5px
color: var(--fg)
outline: none
margin-bottom: 4px
&:focus
border-color: var(--accent)
.move-new
display: flex
flex-shrink: 0
gap: 6px
padding: 6px 2px 2px
border-top: 1px solid var(--border)

View File

@ -13,6 +13,7 @@ export class LibraryBrowserComponent implements OnChanges {
@Input() files: LibraryFile[] = [];
@Input() folderPath = ''; // library-relative path of the current folder
@Input() folders: string[] = [];
@Input() multiRoot = false;
@Input() loading = false;
@Output() play = new EventEmitter<LibraryFile>();
@ -27,6 +28,7 @@ export class LibraryBrowserComponent implements OnChanges {
view: 'list' | 'grid' = 'grid';
selected = new Set<string>();
moveMenuOpen = false;
moveQuery = '';
newFolder = '';
faList = faList;
@ -62,7 +64,7 @@ export class LibraryBrowserComponent implements OnChanges {
}
filePath(file: LibraryFile): string {
return this.folderPath ? this.folderPath + '/' + file.name : file.name;
return file.path || (this.folderPath ? this.folderPath + '/' + file.name : file.name);
}
thumbUrl(file: LibraryFile): string {
@ -106,14 +108,23 @@ export class LibraryBrowserComponent implements OnChanges {
clearSelection(): void {
this.selected.clear();
this.moveMenuOpen = false;
this.moveQuery = '';
}
folderOptions(): { label: string; path: string }[] {
const opts = [{ label: '根目录', path: '' }];
const opts: { label: string; path: string }[] = this.multiRoot ? [] : [{ label: '根目录', path: '' }];
[...this.folders].sort((a, b) => a.localeCompare(b, 'zh')).forEach(f => {
if (f) opts.push({ label: f, path: f });
});
return opts.filter(o => o.path !== this.folderPath);
let result = opts.filter(o => o.path !== this.folderPath);
const q = this.moveQuery.trim().toLowerCase();
if (q) result = result.filter(o => o.label.toLowerCase().includes(q));
return result;
}
toggleMoveMenu(): void {
this.moveMenuOpen = !this.moveMenuOpen;
if (!this.moveMenuOpen) this.moveQuery = '';
}
moveTo(folder: string): void {
@ -121,8 +132,17 @@ export class LibraryBrowserComponent implements OnChanges {
this.clearSelection();
}
get currentRoot(): string {
return this.folderPath ? this.folderPath.split('/')[0] : '';
}
confirmNewFolder(): void {
const folder = this.newFolder.trim();
let folder = this.newFolder.trim();
// Multi-root mode: a bare name is created under the current root unless it
// is an existing path (e.g. a root label).
if (this.multiRoot && folder && !folder.includes('/') && !this.folders.includes(folder)) {
folder = this.currentRoot ? this.currentRoot + '/' + folder : folder;
}
if (folder) this.moveTo(folder);
this.newFolder = '';
}

View File

@ -53,17 +53,42 @@
<input type="search" placeholder="筛选文件夹…" aria-label="筛选文件夹"
[(ngModel)]="folderFilter">
</div>
<button class="nav-item" [class.active]="selectedLibraryFolder === ''" (click)="selectLibraryFolder('')">
<fa-icon [icon]="faHome" class="nav-icon"></fa-icon>
<span>根目录</span>
<span class="nav-count num">{{ libraryRootCount }}</span>
</button>
<button *ngFor="let folder of libraryFoldersNonRoot" class="nav-item" [class.active]="selectedLibraryFolder === folder.name" (click)="selectLibraryFolder(folder.name)">
<fa-icon [icon]="faFolderOpen" class="nav-icon"></fa-icon>
<span>{{ folder.name }}</span>
<span class="nav-count num">{{ folder.count }}</span>
</button>
<div class="nav-empty" *ngIf="folderFilter && libraryFoldersNonRoot.length === 0">无匹配文件夹</div>
<ng-container *ngIf="!multiRoot">
<button class="nav-item" [class.active]="selectedLibraryFolder === ''" (click)="selectLibraryFolder('')">
<fa-icon [icon]="faHome" class="nav-icon"></fa-icon>
<span>根目录</span>
<span class="nav-count num">{{ libraryRootCount }}</span>
</button>
<button *ngFor="let folder of filteredLibraryFolders" class="nav-item" [class.active]="selectedLibraryFolder === folder.path" (click)="selectLibraryFolder(folder.path)">
<fa-icon [icon]="faFolderOpen" class="nav-icon"></fa-icon>
<span>{{ folder.name }}</span>
<span class="nav-count num">{{ folder.count }}</span>
</button>
</ng-container>
<ng-container *ngIf="multiRoot">
<div class="lib-group" *ngFor="let group of libraryGroups">
<div class="lib-group-head">
<button type="button" class="lib-group-toggle" [class.collapsed]="isGroupCollapsed(group.label)"
(click)="toggleGroup(group.label)"
[attr.aria-label]="(isGroupCollapsed(group.label) ? '展开' : '折叠') + ' ' + group.label">
<fa-icon [icon]="faChevronRight"></fa-icon>
</button>
<button class="nav-item" [class.active]="selectedLibraryFolder === group.label" (click)="selectLibraryFolder(group.label)">
<fa-icon [icon]="faFolderOpen" class="nav-icon"></fa-icon>
<span>{{ group.label }}</span>
<span class="nav-count num">{{ group.count }}</span>
</button>
</div>
<ng-container *ngIf="!isGroupCollapsed(group.label)">
<button *ngFor="let folder of group.folders" class="nav-item lib-group-item" [class.active]="selectedLibraryFolder === folder.path" (click)="selectLibraryFolder(folder.path)">
<fa-icon [icon]="faFolderOpen" class="nav-icon"></fa-icon>
<span>{{ folder.name }}</span>
<span class="nav-count num">{{ folder.count }}</span>
</button>
</ng-container>
</div>
</ng-container>
<div class="nav-empty" *ngIf="folderFilter && libraryFilterEmpty">无匹配文件夹</div>
</div>
<div class="sidebar-foot">

View File

@ -147,6 +147,47 @@
flex: none
animation: pulse 1.6s ease-in-out infinite
.lib-group
margin: 1px 0
.lib-group-head
display: flex
align-items: center
gap: 2px
.nav-item
flex: 1
min-width: 0
.lib-group-toggle
flex: none
display: flex
align-items: center
justify-content: center
width: 24px
height: 30px
padding: 0
border: none
border-radius: 6px
background: none
color: var(--muted)
cursor: pointer
&:hover
background: var(--bg)
color: var(--fg)
svg
font-size: 11px
transition: transform 0.15s ease
transform: rotate(90deg)
&.collapsed svg
transform: none
.lib-group-item
padding-left: 30px
@keyframes pulse
50%
opacity: 0.35

View File

@ -1,13 +1,20 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { faCheck, faClock, faDownload, faFolderOpen, faHome, faPhotoVideo } from '@fortawesome/free-solid-svg-icons';
import { faCheck, faChevronRight, faClock, faDownload, faFolderOpen, faHome, faPhotoVideo } from '@fortawesome/free-solid-svg-icons';
import { faCheckCircle } from '@fortawesome/free-regular-svg-icons';
import { Theme } from './theme';
import { LibraryFolder } from './downloads.service';
export interface FolderNav {
name: string;
count: number;
}
export interface LibraryGroup {
label: string;
count: number;
folders: LibraryFolder[];
}
@Component({
selector: 'app-sidebar',
standalone: false,
@ -26,7 +33,7 @@ export class SidebarComponent {
@Input() open = false;
@Input() mode: 'downloads' | 'library' = 'downloads';
@Input() libraryEnabled = false;
@Input() libraryFolders: FolderNav[] = [];
@Input() libraryFolders: LibraryFolder[] = [];
@Input() libraryTotal = 0;
@Input() selectedLibraryFolder = '';
@ -46,22 +53,66 @@ export class SidebarComponent {
faHome = faHome;
faCheck = faCheck;
faPhotoVideo = faPhotoVideo;
faChevronRight = faChevronRight;
folderLabel(name: string): string {
return name || '未分类';
}
folderFilter = '';
collapsedGroups = new Set<string>();
get multiRoot(): boolean {
return this.libraryFolders.some(f => f.label);
}
get libraryRootCount(): number {
return this.libraryFolders.find(f => f.name === '')?.count || 0;
}
get libraryFoldersNonRoot(): FolderNav[] {
get filteredLibraryFolders(): LibraryFolder[] {
const q = this.folderFilter.trim().toLowerCase();
return this.libraryFolders.filter(f => f.name !== '' && (!q || f.name.toLowerCase().includes(q)));
}
get libraryGroups(): LibraryGroup[] {
const groups = new Map<string, LibraryGroup>();
for (const f of this.libraryFolders) {
if (!f.label) continue;
let g = groups.get(f.label);
if (!g) {
g = { label: f.label, count: 0, folders: [] };
groups.set(f.label, g);
}
g.count += f.count;
if (f.name) g.folders.push(f);
}
let result = Array.from(groups.values());
const q = this.folderFilter.trim().toLowerCase();
if (q) {
result = result
.map(g => ({ ...g, folders: g.folders.filter(f => f.name.toLowerCase().includes(q)) }))
.filter(g => g.label.toLowerCase().includes(q) || g.folders.length > 0);
}
return result.sort((a, b) => a.label.localeCompare(b.label, 'zh'));
}
get libraryFilterEmpty(): boolean {
return this.multiRoot ? this.libraryGroups.length === 0 : this.filteredLibraryFolders.length === 0;
}
toggleGroup(label: string): void {
if (this.collapsedGroups.has(label)) {
this.collapsedGroups.delete(label);
} else {
this.collapsedGroups.add(label);
}
}
isGroupCollapsed(label: string): boolean {
return this.collapsedGroups.has(label);
}
selectStatus(status: string): void {
this.statusChange.emit(status);
this.close.emit();