feat: 新增媒体库整理与浏览功能

This commit is contained in:
tigerenwork 2026-08-13 00:28:35 +08:00
parent 4a85bdfaf6
commit 3aa106f003
21 changed files with 1156 additions and 12 deletions

View File

@ -11,3 +11,9 @@
## UI
- Angular app in `ui/`. Build with `cd ui && npm run build`. The Docker image builds the UI itself (see `Dockerfile`), so no need to commit `ui/dist`.
## Media library (organize/browse) feature
- `LIBRARY_DIR` (empty = feature off) points to a separate media library directory, mounted as its own volume. See `LIBRARY_ORGANIZE_DESIGN.md` for the full design.
- Library state is read from disk on demand — no DB/index. `/library/folders` is cached ~60s in memory; organize/delete invalidate it.
- Organizing a completed download moves its file into the library and removes the done entry (a `cleared` socket event updates the UI).

114
LIBRARY_ORGANIZE_DESIGN.md Normal file
View File

@ -0,0 +1,114 @@
# 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=<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。
### 整理流程(已完成视图)
- 批量操作条新增"**整理到媒体库…**"按钮(在"移动到…"旁),选中条目后点击打开整理面板:
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`(批准后开始实现时一并提交)。
- 全部代码改动 + 本地容器验证。

View File

@ -56,6 +56,8 @@ Certain values can be set via environment variables, using the `-e` parameter on
* __TEMP_DIR__: Path where intermediary download files will be saved. Defaults to `/downloads` in the Docker image, and `.` otherwise.
* 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.
* __PUBLIC_HOST_LIBRARY_URL__: URL prefix under which library files are served. Defaults to `library/`.
### 📝 File Naming & yt-dlp

View File

@ -14,6 +14,7 @@ import logging
import json
import pathlib
import re
import time
import base64
import hashlib
import aiohttp
@ -36,6 +37,8 @@ class Config:
'CUSTOM_DIRS_EXCLUDE_REGEX': r'(^|/)[.@].*$',
'DELETE_FILE_ON_TRASHCAN': 'true',
'STATE_DIR': '.',
'LIBRARY_DIR': '',
'PUBLIC_HOST_LIBRARY_URL': 'library/',
'URL_PREFIX': '',
'PUBLIC_HOST_URL': 'download/',
'PUBLIC_HOST_AUDIO_URL': 'audio_download/',
@ -357,6 +360,161 @@ def sniff_image_type(data: bytes) -> str:
return 'application/octet-stream'
# ---------- Library (large media collection, separate from DOWNLOAD_DIR) ----------
VIDEO_EXTENSIONS = ('.mp4', '.mkv', '.webm', '.avi', '.mov', '.m4v', '.ts', '.flv', '.mpg', '.mpeg', '.wmv', '.m2ts')
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)
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
return root, path
_library_folders_cache = {'time': 0.0, 'data': None}
_LIBRARY_FOLDERS_TTL = 60
def _scan_library_folders(root):
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())
return folders
def _invalidate_library_folders():
_library_folders_cache['time'] = 0.0
@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')
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['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', ''))
if root is None:
raise web.HTTPBadRequest(text='LIBRARY_DIR is not configured')
if path is None or not os.path.isdir(path):
raise web.HTTPBadRequest(text='Invalid library folder')
def _list():
entries = []
with os.scandir(path) as it:
for e in it:
if not e.is_file(follow_symlinks=False):
continue
if os.path.splitext(e.name)[1].lower() not in VIDEO_EXTENSIONS:
continue
st = e.stat()
entries.append({'name': e.name, 'size': st.st_size, 'mtime': st.st_mtime})
entries.sort(key=lambda f: f['name'].lower())
return entries
files = await asyncio.get_running_loop().run_in_executor(None, _list)
return web.Response(text=serializer.encode({'files': files}))
@routes.post(config.URL_PREFIX + 'library/organize')
async def library_organize(request):
post = await request.json()
ids = post.get('ids')
folder = post.get('folder', '')
if not ids:
log.error("Bad request: missing 'ids'")
raise web.HTTPBadRequest()
status = await dqueue.organize_to_library(ids, folder)
if status.get('moved'):
_invalidate_library_folders()
log.info(f"Organize request processed: moved={status.get('moved')} errors={len(status.get('errors', []))}")
return web.Response(text=serializer.encode(status))
@routes.post(config.URL_PREFIX + 'library/delete')
async def library_delete(request):
post = await request.json()
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')
deleted = 0
errors = []
for rel in paths:
_, path = _resolve_library_path(rel)
if path is None or not os.path.isfile(path):
errors.append(f'{rel}: invalid path')
continue
try:
os.remove(path)
deleted += 1
# Clean up emptied parent directories, never the library root itself
parent = os.path.dirname(path)
while parent != root and parent.startswith(root + os.sep):
try:
os.rmdir(parent)
except OSError:
break
parent = os.path.dirname(parent)
except OSError as e:
errors.append(f'{rel}: {e}')
if deleted:
_invalidate_library_folders()
log.info(f"Library delete request processed: deleted={deleted} errors={len(errors)}")
return web.Response(text=serializer.encode({'status': 'ok', 'deleted': deleted, 'errors': errors}))
@routes.get(config.URL_PREFIX + 'library/thumbnail')
async def library_thumbnail(request):
"""Thumbnail for a library file: ffmpeg frame extraction, cached on disk."""
rel = request.query.get('path')
root, path = _resolve_library_path(rel)
if root is None or path is None or not os.path.isfile(path):
return _placeholder_thumb_response()
thumb_dir = os.path.join(config.STATE_DIR, 'thumbnails')
os.makedirs(thumb_dir, exist_ok=True)
thumb_path = os.path.join(thumb_dir, hashlib.sha1(('lib:' + rel).encode('utf-8')).hexdigest() + '.img')
if not os.path.exists(thumb_path):
await _extract_video_frame(path, thumb_path, None)
if not os.path.exists(thumb_path):
return _placeholder_thumb_response()
with open(thumb_path, 'rb') as f:
data = f.read()
return web.Response(
body=data,
content_type=sniff_image_type(data[:16]),
headers={'Cache-Control': 'public, max-age=31536000, immutable'}
)
def _placeholder_thumb_response():
return web.Response(text=PLACEHOLDER_THUMB, content_type='image/svg+xml', headers={'Cache-Control': 'public, max-age=3600'})
@ -597,6 +755,8 @@ 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)
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

@ -967,6 +967,55 @@ class DownloadQueue:
log.info(f"Changed folder for completed download {id} to '{folder or 'Default'}'")
return None
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': []}
os.makedirs(target_dir, exist_ok=True)
moved = 0
errors = []
for id in ids:
if not self.done.exists(id):
errors.append(f'{id}: not a completed download')
continue
dl = self.done.get(id)
filename = getattr(dl.info, 'filename', None)
if not filename:
errors.append(f'{dl.info.title}: no file on record')
continue
src_dir, error = self.__calc_download_path(dl.info.quality, dl.info.format, getattr(dl.info, 'folder', None))
src = os.path.join(src_dir, filename) if src_dir else None
if not src or not os.path.exists(src):
errors.append(f'{dl.info.title}: file not found on disk')
continue
dst = os.path.join(target_dir, os.path.basename(filename))
if os.path.exists(dst):
base, ext = os.path.splitext(os.path.basename(filename))
unique_id = ''.join(random.choices(string.ascii_lowercase + string.digits, k=5))
dst = os.path.join(target_dir, f"{base}_{unique_id}{ext}")
try:
await asyncio.get_running_loop().run_in_executor(None, shutil.move, src, dst)
except OSError as e:
errors.append(f'{dl.info.title}: {e}')
continue
log.info(f"Organized download {id} into library: {src} -> {dst}")
self.done.delete(id)
await self.notifier.cleared(id)
moved += 1
# Clean up emptied source subdirectories (e.g. playlist dirs)
src_subdir = os.path.dirname(src)
if src_subdir != src_dir:
try:
os.removedirs(src_subdir)
except OSError:
pass
return {'status': 'ok', 'moved': moved, 'errors': errors}
async def cancel(self, ids):
for id in ids:
if self.pending.exists(id):

View File

@ -11,6 +11,8 @@ 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
environment:
@ -23,6 +25,8 @@ services:
- DOWNLOAD_DIR=/downloads
- STATE_DIR=/config
- TEMP_DIR=/downloads
# Media library (organize/browse feature); leave empty to disable
- LIBRARY_DIR=/library
# Download behavior
- DOWNLOAD_MODE=limited

View File

@ -9,8 +9,15 @@
[themes]="themes"
[activeTheme]="activeTheme"
[open]="sidebarOpen"
[mode]="mode"
[libraryEnabled]="libraryEnabled"
[libraryFolders]="libraryFolders"
[libraryTotal]="libraryTotal"
[selectedLibraryFolder]="libraryFolder"
(statusChange)="onStatusChange($event)"
(folderChange)="onFolderChange($event)"
(modeChange)="setMode($event)"
(libraryFolderChange)="selectLibraryFolder($event)"
(openAdvanced)="toggleAdvanced(true)"
(openActivity)="activityOpen = !activityOpen"
(themeChanged)="themeChanged($event)"
@ -44,7 +51,7 @@
[storage]="librarySize">
</app-stats-strip>
<div class="content">
<div class="content" [class.d-none]="mode !== 'downloads'">
<app-active-downloads
[items]="downloadingRows"
[totalSpeed]="totalSpeed"
@ -68,6 +75,7 @@
[customDirsFor]="customDirsForDownload.bind(this)"
[allowCustomDir]="allowCustomDir.bind(this)"
[downloadUrlFor]="buildDownloadLink.bind(this)"
[libraryEnabled]="libraryEnabled"
(start)="startItems($event)"
(del)="delItems($event, status === 'completed' ? 'done' : 'queue')"
(move)="moveItems($event.ids, $event.folder)"
@ -77,9 +85,21 @@
(clearFailed)="clearFailedDownloads()"
(retryFailed)="retryFailedDownloads()"
(downloadSelected)="downloadSelectedFiles($event)"
(play)="openPlayer($event)">
(play)="openPlayer($event)"
(organize)="openOrganize($event)">
</app-download-list>
</div>
<div class="content" *ngIf="mode === 'library'">
<app-library-browser
[files]="libraryFiles"
[folderPath]="libraryFolder"
[loading]="libraryLoading"
(play)="playLibraryFile($event)"
(del)="deleteLibraryFile($event)"
(refresh)="loadLibraryFolders(); loadLibraryFiles()">
</app-library-browser>
</div>
</main>
</div>
@ -127,6 +147,40 @@
</div>
</div>
<!-- Organize to library modal -->
<div class="modal fade" tabindex="-1" role="dialog" [ngClass]="{'show': organizeOpen}" [ngStyle]="{'display': organizeOpen ? 'block' : 'none'}">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">整理到媒体库</h5>
<button type="button" class="btn-close" aria-label="关闭" (click)="closeOrganize()"></button>
</div>
<div class="modal-body">
<p>将 {{ organizeItems.length }} 个文件移动到媒体库文件夹:</p>
<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>
</div>
<ng-select [items]="libraryFolderNames"
[(ngModel)]="organizeTarget"
[addTag]="true"
addTagText="新建文件夹"
placeholder="搜索或新建文件夹…"
[searchable]="true"
[clearable]="true"
appendTo="body">
</ng-select>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" (click)="closeOrganize()">取消</button>
<button type="button" class="btn btn-primary" (click)="confirmOrganize(organizeTarget)" [disabled]="!organizeTarget || organizeInProgress">
{{ organizeInProgress ? '整理中…' : '整理' }}
</button>
</div>
</div>
</div>
</div>
<!-- Activity drawer -->
<div class="scrim" *ngIf="activityOpen" (click)="activityOpen = false"></div>
<aside class="activity" [class.show]="activityOpen" aria-label="活动日志">

View File

@ -2,7 +2,7 @@ import { Component, HostListener, OnInit } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, distinctUntilChanged, map } from 'rxjs';
import { Download, DownloadsService, Status } from './downloads.service';
import { Download, DownloadsService, LibraryFile, LibraryFolder, Status } from './downloads.service';
import { Formats, Format, Quality } from './formats';
import { Theme, Themes } from './theme';
import { PreferencesService } from './preferences.service';
@ -49,6 +49,20 @@ export class AppComponent implements OnInit {
playerUrl: string | null = null;
playerTitle = '';
// Library (媒体库) mode
mode: 'downloads' | 'library' = 'downloads';
libraryEnabled = false;
libraryFolders: LibraryFolder[] = [];
libraryFiles: LibraryFile[] = [];
libraryFolder = ''; // '' = library root
libraryLoading = false;
// Organize-to-library modal
organizeOpen = false;
organizeItems: KeyedDownload[] = [];
organizeTarget: string | null = null;
organizeInProgress = false;
// Metrics
activeDownloads = 0;
queuedDownloads = 0;
@ -90,6 +104,7 @@ export class AppComponent implements OnInit {
this.selectedFolder = savedFolder;
const savedView = this.preferences.get('metube_view', 'list');
this.view = savedView === 'grid' ? 'grid' : 'list';
this.mode = this.preferences.get('metube_mode', 'downloads') === 'library' ? 'library' : 'downloads';
this.activeTheme = this.getPreferredTheme();
this.downloads.queueChanged.subscribe(() => this.updateMetrics());
@ -119,6 +134,7 @@ export class AppComponent implements OnInit {
onStatusChange(status: DownloadTab): void {
this.status = status;
this.preferences.set('metube_status', status);
if (this.mode !== 'downloads') this.setMode('downloads');
}
onFolderChange(folder: string): void {
@ -157,6 +173,146 @@ export class AppComponent implements OnInit {
if (this.playerUrl) this.closePlayer();
}
// ---------- Library (媒体库) ----------
setMode(mode: 'downloads' | 'library'): void {
this.mode = mode;
this.preferences.set('metube_mode', mode);
if (mode === 'library') {
this.loadLibraryFolders();
this.loadLibraryFiles();
}
}
loadLibraryFolders(): void {
if (!this.libraryEnabled) return;
this.downloads.libraryFolders().subscribe({
next: (res) => { this.libraryFolders = res.folders; },
error: () => { this.libraryFolders = []; }
});
}
selectLibraryFolder(folder: string): void {
this.libraryFolder = folder;
this.loadLibraryFiles();
}
loadLibraryFiles(): void {
if (!this.libraryEnabled) return;
this.libraryLoading = true;
this.downloads.libraryFiles(this.libraryFolder).subscribe({
next: (res) => { this.libraryFiles = res.files; this.libraryLoading = false; },
error: () => { this.libraryFiles = []; this.libraryLoading = false; }
});
}
get libraryTotal(): number {
return this.libraryFolders.reduce((total, f) => total + f.count, 0);
}
libraryFilePath(file: LibraryFile): string {
return this.libraryFolder ? this.libraryFolder + '/' + file.name : file.name;
}
playLibraryFile(file: LibraryFile): void {
this.playerUrl = this.downloads.libraryFileUrl(this.libraryFilePath(file));
this.playerTitle = file.name;
}
deleteLibraryFile(file: LibraryFile): void {
if (!confirm(`确定删除「${file.name}」吗?此操作不可恢复。`)) return;
this.downloads.libraryDelete([this.libraryFilePath(file)]).subscribe((res) => {
if (res.status === 'error') {
this.toastService.show(`删除失败: ${res.msg}`, { error: true });
return;
}
if (res.errors?.length) {
this.toastService.show(res.errors.join('; '), { error: true });
} else {
this.toastService.show('已删除');
}
this.loadLibraryFiles();
this.loadLibraryFolders();
});
}
// ---------- Organize to library ----------
openOrganize(keys: string[]): void {
const items: KeyedDownload[] = [];
keys.forEach(key => {
const dl = this.downloads.done.get(key);
if (dl && dl.status === 'finished') items.push({ key, value: dl });
});
if (!items.length) {
this.toastService.show('所选任务没有可整理的文件', { error: true });
return;
}
this.organizeItems = items;
this.organizeTarget = null;
this.organizeInProgress = false;
this.organizeOpen = true;
if (this.libraryFolders.length === 0) this.loadLibraryFolders();
}
closeOrganize(): void {
this.organizeOpen = false;
}
get libraryFolderNames(): string[] {
return this.libraryFolders.map(f => f.name).filter(n => n);
}
private normalizeName(s: string): string {
return (s || '').toLowerCase().replace(/[^a-z0-9\u4e00-\u9fff]+/g, ' ').trim();
}
// Best-guess library folders for the items being organized: uploader match
// is the strongest signal, then title-token overlap
get organizeSuggestions(): string[] {
const scores = new Map<string, number>();
for (const item of this.organizeItems) {
const uploader = this.normalizeName(item.value.entry?.uploader || '');
const titleTokens = new Set(this.normalizeName(item.value.title).split(' ').filter(t => t.length > 2));
for (const f of this.libraryFolders) {
if (!f.name) continue;
const folderNorm = this.normalizeName(f.name);
if (!folderNorm) continue;
let score = 0;
if (uploader && folderNorm === uploader) score += 100;
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);
}
}
return Array.from(scores.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 3)
.map(([name]) => name);
}
confirmOrganize(folder: string | null): void {
if (folder === null || this.organizeInProgress) return;
const ids = this.organizeItems.map(i => i.key);
this.organizeInProgress = true;
this.downloads.libraryOrganize(ids, folder).subscribe((res) => {
this.organizeInProgress = false;
if (res.status === 'error') {
this.toastService.show(`整理失败: ${res.msg}`, { error: true });
return;
}
this.organizeOpen = false;
if (res.errors?.length) {
this.toastService.show(`已整理 ${res.moved || 0} 个文件,${res.errors.length} 个失败: ${res.errors[0]}`, { error: true });
} else {
this.toastService.show(`已整理 ${res.moved || 0} 个文件到「${folder || '根目录'}」`);
}
this.loadLibraryFolders();
if (this.mode === 'library') this.loadLibraryFiles();
});
}
// ---------- Data getters ----------
statusEntries(tab: DownloadTab): KeyedDownload[] {
@ -548,6 +704,10 @@ export class AppComponent implements OnInit {
this.playlistStrictMode = config['DEFAULT_OPTION_PLAYLIST_STRICT_MODE'];
const limit = config['DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT'];
if (limit !== '0') this.playlistItemLimit = limit;
const wasEnabled = this.libraryEnabled;
this.libraryEnabled = !!config['LIBRARY_DIR'];
if (!this.libraryEnabled && this.mode === 'library') this.mode = 'downloads';
if (this.libraryEnabled && !wasEnabled && this.libraryFolders.length === 0) this.loadLibraryFolders();
}
});
}

View File

@ -20,6 +20,7 @@ import { DownloadListComponent } from './download-list.component';
import { DownloadRowComponent } from './download-row.component';
import { DownloadCardComponent } from './download-card.component';
import { AdvancedOptionsComponent } from './advanced-options.component';
import { LibraryBrowserComponent } from './library-browser.component';
import { ToastsComponent } from './toasts.component';
@NgModule({ declarations: [
@ -37,6 +38,7 @@ import { ToastsComponent } from './toasts.component';
DownloadRowComponent,
DownloadCardComponent,
AdvancedOptionsComponent,
LibraryBrowserComponent,
ToastsComponent
],
bootstrap: [AppComponent], imports: [BrowserModule,

View File

@ -19,6 +19,9 @@
<button *ngIf="downloadUrl" type="button" class="icon-btn neutral" (click)="play.emit()" title="在线播放" aria-label="在线播放">
<fa-icon [icon]="faPlay"></fa-icon>
</button>
<button *ngIf="libraryEnabled && downloadUrl" type="button" class="icon-btn neutral" (click)="organize.emit()" title="整理到媒体库" aria-label="整理到媒体库">
<fa-icon [icon]="faFolderPlus"></fa-icon>
</button>
<a *ngIf="downloadUrl" class="icon-btn neutral" [href]="downloadUrl" [attr.download]="row.value.filename" title="下载文件">
<fa-icon [icon]="faDownload"></fa-icon>
</a>

View File

@ -1,5 +1,5 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { faDownload, faExternalLinkAlt, faPlay, faRedoAlt, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
import { faDownload, faExternalLinkAlt, faFolderPlus, faPlay, faRedoAlt, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
import { faCheckCircle, faTimesCircle } from '@fortawesome/free-regular-svg-icons';
import { Download, DownloadsService } from './downloads.service';
import { KeyedDownload } from './active-downloads.component';
@ -17,15 +17,18 @@ export class DownloadCardComponent {
@Input() customDirs: string[] = [];
@Input() allowCustomDir: (tag: string) => string | boolean = () => false;
@Input() downloadUrl = '';
@Input() libraryEnabled = false;
@Output() selectChange = new EventEmitter<boolean>();
@Output() del = new EventEmitter<string>();
@Output() retry = new EventEmitter<{ key: string; dl: Download }>();
@Output() folderChange = new EventEmitter<{ key: string; folder: string }>();
@Output() play = new EventEmitter<void>();
@Output() organize = new EventEmitter<void>();
faDownload = faDownload;
faExternalLinkAlt = faExternalLinkAlt;
faFolderPlus = faFolderPlus;
faPlay = faPlay;
faRedoAlt = faRedoAlt;
faTrashAlt = faTrashAlt;

View File

@ -54,12 +54,14 @@
[customDirs]="customDirsFor(row.value)"
[allowCustomDir]="allowCustomDir"
[downloadUrl]="status === 'completed' ? downloadUrlFor(row.value) : ''"
[libraryEnabled]="libraryEnabled"
(selectChange)="toggleSelect(row.key, $event)"
(start)="start.emit([$event])"
(del)="del.emit([$event])"
(retry)="retry.emit($event)"
(folderChange)="folderEdit.emit($event)"
(play)="play.emit(row)">
(play)="play.emit(row)"
(organize)="organize.emit([row.key])">
</app-download-row>
</div>
</cdk-virtual-scroll-viewport>
@ -72,11 +74,13 @@
[customDirs]="customDirsFor(row.value)"
[allowCustomDir]="allowCustomDir"
[downloadUrl]="downloadUrlFor(row.value)"
[libraryEnabled]="libraryEnabled"
(selectChange)="toggleSelect(row.key, $event)"
(del)="del.emit([$event])"
(retry)="retry.emit($event)"
(folderChange)="folderEdit.emit($event)"
(play)="play.emit(row)">
(play)="play.emit(row)"
(organize)="organize.emit([row.key])">
</app-download-card>
</div>
</div>
@ -126,6 +130,10 @@
</div>
</div>
</div>
<button *ngIf="status === 'completed' && libraryEnabled" type="button" class="batch-btn" (click)="organizeSelected()">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><polyline points="10 9 15 12 10 15"/></svg>
整理到媒体库…
</button>
<button type="button" class="batch-btn danger" (click)="delSelected()">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
删除

View File

@ -23,6 +23,7 @@ export class DownloadListComponent implements OnChanges {
@Input() customDirsFor: (dl: Download) => string[] = () => [];
@Input() allowCustomDir: (tag: string) => string | boolean = () => false;
@Input() downloadUrlFor: (dl: Download) => string = () => '';
@Input() libraryEnabled = false;
@Output() filterChange = new EventEmitter<string>();
@Output() sortChange = new EventEmitter<string>();
@ -38,6 +39,7 @@ export class DownloadListComponent implements OnChanges {
@Output() retryFailed = new EventEmitter<void>();
@Output() downloadSelected = new EventEmitter<string[]>();
@Output() play = new EventEmitter<KeyedDownload>();
@Output() organize = new EventEmitter<string[]>();
faList = faList;
faThLarge = faThLarge;
@ -118,6 +120,11 @@ export class DownloadListComponent implements OnChanges {
this.clearSelection();
}
organizeSelected(): void {
this.organize.emit(Array.from(this.selected));
this.clearSelection();
}
folderOptions(): string[] {
const set = new Set<string>();
this.rows.forEach(r => {

View File

@ -42,6 +42,9 @@
<button *ngIf="status === 'completed' && downloadUrl" type="button" class="icon-btn neutral" (click)="play.emit()" title="在线播放" aria-label="在线播放">
<fa-icon [icon]="faPlay"></fa-icon>
</button>
<button *ngIf="status === 'completed' && libraryEnabled && downloadUrl" type="button" class="icon-btn neutral" (click)="organize.emit()" title="整理到媒体库" aria-label="整理到媒体库">
<fa-icon [icon]="faFolderPlus"></fa-icon>
</button>
<a *ngIf="status === 'completed' && downloadUrl" class="icon-btn neutral" [href]="downloadUrl" [attr.download]="row.value.filename" title="下载文件">
<fa-icon [icon]="faDownload"></fa-icon>
</a>

View File

@ -1,5 +1,5 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { faDownload, faExternalLinkAlt, faPlay, faRedoAlt, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
import { faDownload, faExternalLinkAlt, faFolderPlus, faPlay, faRedoAlt, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
import { faCheckCircle, faTimesCircle } from '@fortawesome/free-regular-svg-icons';
import { Download, DownloadsService } from './downloads.service';
import { KeyedDownload } from './active-downloads.component';
@ -18,6 +18,7 @@ export class DownloadRowComponent {
@Input() customDirs: string[] = [];
@Input() allowCustomDir: (tag: string) => string | boolean = () => false;
@Input() downloadUrl = '';
@Input() libraryEnabled = false;
@Output() selectChange = new EventEmitter<boolean>();
@Output() start = new EventEmitter<string>();
@ -25,9 +26,11 @@ export class DownloadRowComponent {
@Output() retry = new EventEmitter<{ key: string; dl: Download }>();
@Output() folderChange = new EventEmitter<{ key: string; folder: string }>();
@Output() play = new EventEmitter<void>();
@Output() organize = new EventEmitter<void>();
faDownload = faDownload;
faExternalLinkAlt = faExternalLinkAlt;
faFolderPlus = faFolderPlus;
faPlay = faPlay;
faRedoAlt = faRedoAlt;
faTrashAlt = faTrashAlt;

View File

@ -9,6 +9,17 @@ export interface Status {
msg?: string;
}
export interface LibraryFolder {
name: string;
count: number;
}
export interface LibraryFile {
name: string;
size: number;
mtime: number;
}
export interface Download {
id: string;
title: string;
@ -33,6 +44,7 @@ export interface Download {
file_exists?: boolean;
checked?: boolean;
deleting?: boolean;
entry?: any;
}
@Injectable({
@ -186,6 +198,37 @@ export class DownloadsService {
return 'thumbnail?id=' + encodeURIComponent(id);
}
// ---------- Library ----------
public libraryFolders() {
return this.http.get<{ folders: LibraryFolder[] }>('library/folders');
}
public libraryFiles(folder: string) {
return this.http.get<{ files: LibraryFile[] }>('library/files', { params: { folder } });
}
public libraryOrganize(ids: string[], folder: string): Observable<Status & { moved?: number; errors?: string[] }> {
return this.http.post<Status & { moved?: number; errors?: string[] }>('library/organize', { ids, folder }).pipe(
catchError(this.handleHTTPError)
);
}
public libraryDelete(paths: string[]): Observable<Status & { deleted?: number; errors?: string[] }> {
return this.http.post<Status & { deleted?: number; errors?: string[] }>('library/delete', { paths }).pipe(
catchError(this.handleHTTPError)
);
}
public libraryThumbUrl(path: string): string {
return 'library/thumbnail?path=' + encodeURIComponent(path);
}
public libraryFileUrl(path: string): string {
const base = this.configuration['PUBLIC_HOST_LIBRARY_URL'] || 'library/';
return base + path.split('/').map(encodeURIComponent).join('/');
}
public addDownloadByUrl(url: string): Promise<any> {
const defaultQuality = 'best';
const defaultFormat = 'mp4';

View File

@ -0,0 +1,88 @@
<section class="list-section">
<div class="section-head">
<h2 class="section-title">媒体库</h2>
<span class="section-meta num">{{ folderLabel }} · {{ files.length }} 项</span>
</div>
<div class="list-toolbar">
<div class="filter-wrap">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="search" placeholder="筛选文件名…" aria-label="筛选文件名"
[(ngModel)]="filter">
</div>
<select class="tool-select" aria-label="排序方式" [(ngModel)]="sort">
<option value="name">按名称</option>
<option value="date">按时间</option>
<option value="size">按大小</option>
</select>
<button class="sort-dir" (click)="sortAscending = !sortAscending" [title]="sortAscending ? '升序,点击切换' : '降序,点击切换'">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline [attr.points]="sortAscending ? '6 15 12 9 18 15' : '6 9 12 15 18 9'"/></svg>
</button>
<div class="view-toggle" aria-label="视图切换">
<button type="button" [class.on]="view === 'list'" (click)="view = 'list'" title="列表视图" aria-label="列表视图">
<fa-icon [icon]="faList"></fa-icon>
</button>
<button type="button" [class.on]="view === 'grid'" (click)="view = 'grid'" title="网格视图" aria-label="网格视图">
<fa-icon [icon]="faThLarge"></fa-icon>
</button>
</div>
<button class="ghost-btn" (click)="refresh.emit()" title="刷新">
<fa-icon [icon]="faRedoAlt"></fa-icon>&nbsp;刷新
</button>
<span class="result-count num">显示 {{ filteredFiles.length }} / {{ files.length }}</span>
</div>
<div class="empty" *ngIf="!loading && files.length === 0">
<svg width="34" height="34" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><polyline points="10 9 15 12 10 15"/></svg>
<div class="empty-title">此文件夹为空</div>
<div class="empty-sub">从「已完成」整理文件到媒体库后会出现在这里</div>
</div>
<div class="list-frame" *ngIf="files.length > 0">
<div class="lib-rows" *ngIf="view === 'list'">
<div class="lib-row" *ngFor="let file of filteredFiles; trackBy: trackByName">
<div class="thumb">
<img [src]="thumbUrl(file)" [alt]="file.name" loading="lazy">
</div>
<div class="lib-main">
<div class="lib-title" [title]="file.name">{{ file.name }}</div>
<div class="lib-meta">
<span class="num">{{ file.size | fileSize }}</span>
<span>{{ fileTime(file) }}</span>
</div>
</div>
<div class="lib-actions">
<button type="button" class="icon-btn neutral" (click)="play.emit(file)" title="在线播放" aria-label="在线播放">
<fa-icon [icon]="faPlay"></fa-icon>
</button>
<button type="button" class="icon-btn" (click)="del.emit(file)" title="删除" aria-label="删除">
<fa-icon [icon]="faTrashAlt"></fa-icon>
</button>
</div>
</div>
</div>
<div class="grid" *ngIf="view === 'grid'">
<div class="card" *ngFor="let file of filteredFiles; trackBy: trackByName">
<div class="thumb" (click)="play.emit(file)">
<img [src]="thumbUrl(file)" [alt]="file.name" loading="lazy">
</div>
<div class="card-body">
<div class="card-title" [title]="file.name">{{ file.name }}</div>
<div class="card-meta">
<span class="num">{{ file.size | fileSize }}</span>
<span>{{ fileTime(file) }}</span>
</div>
<div class="card-actions">
<button type="button" class="icon-btn neutral" (click)="play.emit(file)" title="在线播放" aria-label="在线播放">
<fa-icon [icon]="faPlay"></fa-icon>
</button>
<button type="button" class="icon-btn" (click)="del.emit(file)" title="删除" aria-label="删除">
<fa-icon [icon]="faTrashAlt"></fa-icon>
</button>
</div>
</div>
</div>
</div>
</div>
</section>

View File

@ -0,0 +1,325 @@
:host
display: flex
flex-direction: column
flex: 1
min-height: 160px
.list-section
display: flex
flex-direction: column
flex: 1
min-height: 0
.section-head
display: flex
align-items: baseline
gap: 10px
margin-bottom: 10px
flex: none
.section-title
font-family: var(--font-display)
font-size: 15px
font-weight: 600
letter-spacing: -0.01em
line-height: 1.35
margin: 0
.section-meta
font-size: 12px
color: var(--muted)
font-family: var(--font-mono)
.list-toolbar
display: flex
align-items: center
gap: 8px
margin-bottom: 12px
flex-wrap: wrap
flex: none
.filter-wrap
display: flex
align-items: center
gap: 7px
border: 1px solid var(--border)
background: var(--surface)
border-radius: 6px
padding: 0 10px
height: 34px
width: 240px
transition: border-color 0.12s ease
&:focus-within
border-color: var(--accent)
svg
color: var(--muted)
flex: none
input
border: none
background: none
outline: none
font-size: 13px
color: var(--fg)
width: 100%
&::placeholder
color: var(--muted)
.tool-select
height: 34px
border: 1px solid var(--border)
background: var(--surface)
border-radius: 6px
font-size: 12.5px
color: var(--fg)
padding: 0 8px
.sort-dir
width: 34px
height: 34px
display: grid
place-items: center
border: 1px solid var(--border)
background: var(--surface)
border-radius: 6px
color: var(--muted)
transition: background 0.12s ease, color 0.12s ease
&:hover
background: var(--bg)
color: var(--fg)
.view-toggle
display: flex
border: 1px solid var(--border)
border-radius: 6px
overflow: hidden
background: var(--surface)
button
width: 34px
height: 32px
border: none
background: none
display: grid
place-items: center
color: var(--muted)
transition: background 0.12s ease, color 0.12s ease
& + button
border-left: 1px solid var(--border)
&.on
background: var(--fg)
color: var(--surface)
&:not(.on):hover
background: var(--bg)
color: var(--fg)
.ghost-btn
display: inline-flex
align-items: center
gap: 7px
padding: 6px 10px
border: 1px solid var(--border)
background: var(--surface)
border-radius: 6px
font-size: 12.5px
color: var(--fg)
transition: background 0.12s ease, border-color 0.12s ease
&:hover
background: var(--bg)
border-color: oklch(82% 0.01 240)
.result-count
margin-left: auto
font-size: 12px
color: var(--muted)
font-family: var(--font-mono)
.empty
text-align: center
padding: 56px 20px
border: 1px dashed var(--border)
border-radius: var(--radius)
background: var(--surface)
svg
color: var(--muted)
margin-bottom: 12px
.empty-title
font-size: 14px
font-weight: 600
margin-bottom: 4px
.empty-sub
font-size: 12.5px
color: var(--muted)
.list-frame
flex: 1
min-height: 0
display: flex
flex-direction: column
background: var(--surface)
border: 1px solid var(--border)
border-radius: var(--radius)
overflow: hidden
// List view
.lib-rows
flex: 1
min-height: 0
overflow-y: auto
.lib-row
display: grid
grid-template-columns: 88px 1fr auto
gap: 12px
align-items: center
padding: 10px 14px
border-bottom: 1px solid var(--border)
&:last-child
border-bottom: none
.thumb
width: 88px
aspect-ratio: 16 / 9
border-radius: 5px
overflow: hidden
background: var(--placeholder)
img
display: block
width: 100%
height: 100%
object-fit: cover
.lib-main
min-width: 0
.lib-title
font-size: 13px
font-weight: 550
color: var(--fg)
white-space: nowrap
overflow: hidden
text-overflow: ellipsis
.lib-meta
display: flex
gap: 10px
margin-top: 4px
font-size: 11.5px
color: var(--muted)
.num
color: var(--fg)
font-family: var(--font-mono)
.lib-actions
display: flex
gap: 2px
// Grid view
.grid
flex: 1
min-height: 0
overflow-y: auto
padding: 14px
display: grid
grid-template-columns: repeat(auto-fill, minmax(218px, 1fr))
gap: 14px
align-content: start
.card
background: var(--surface)
border: 1px solid var(--border)
border-radius: var(--radius)
overflow: hidden
transition: border-color 0.12s ease, box-shadow 0.12s ease
&:hover
border-color: oklch(80% 0.012 240)
box-shadow: 0 4px 14px oklch(30% 0.02 240 / 0.08)
.thumb
aspect-ratio: 16 / 9
overflow: hidden
background: var(--placeholder)
cursor: pointer
img
display: block
width: 100%
height: 100%
object-fit: cover
.card-body
padding: 10px 12px 12px
.card-title
font-size: 13px
font-weight: 550
line-height: 1.45
display: -webkit-box
-webkit-line-clamp: 2
-webkit-box-orient: vertical
overflow: hidden
min-height: 38px
.card-meta
display: flex
align-items: center
gap: 10px
margin-top: 8px
font-size: 11.5px
color: var(--muted)
.num
color: var(--fg)
font-family: var(--font-mono)
.card-actions
display: flex
gap: 2px
margin-top: 8px
align-items: center
.icon-btn
width: 28px
height: 28px
display: grid
place-items: center
border: 1px solid transparent
background: none
border-radius: 6px
color: var(--muted)
text-decoration: none
transition: background 0.12s ease, color 0.12s ease
font-size: 12px
&:hover
background: var(--danger-tint)
color: var(--danger)
&.neutral:hover
background: var(--bg)
color: var(--fg)
@media (max-width: 900px)
.filter-wrap
width: 100%
.result-count
margin-left: 0
.grid
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr))

View File

@ -0,0 +1,65 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { faList, faPlay, faRedoAlt, faThLarge, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
import { DownloadsService, LibraryFile } from './downloads.service';
import { relativeTime } from './labels';
@Component({
selector: 'app-library-browser',
standalone: false,
templateUrl: './library-browser.component.html',
styleUrls: ['./library-browser.component.sass']
})
export class LibraryBrowserComponent {
@Input() files: LibraryFile[] = [];
@Input() folderPath = ''; // library-relative path of the current folder
@Input() loading = false;
@Output() play = new EventEmitter<LibraryFile>();
@Output() del = new EventEmitter<LibraryFile>();
@Output() refresh = new EventEmitter<void>();
filter = '';
sort: 'name' | 'date' | 'size' = 'name';
sortAscending = true;
view: 'list' | 'grid' = 'grid';
faList = faList;
faThLarge = faThLarge;
faPlay = faPlay;
faTrashAlt = faTrashAlt;
faRedoAlt = faRedoAlt;
constructor(public downloads: DownloadsService) {}
get filteredFiles(): LibraryFile[] {
let files = this.files;
const q = this.filter.trim().toLowerCase();
if (q) files = files.filter(f => f.name.toLowerCase().includes(q));
const dir = this.sortAscending ? 1 : -1;
return [...files].sort((a, b) => {
if (this.sort === 'size') return (a.size - b.size) * dir;
if (this.sort === 'date') return (a.mtime - b.mtime) * dir;
return a.name.localeCompare(b.name, 'zh') * dir;
});
}
get folderLabel(): string {
return this.folderPath || '根目录';
}
filePath(file: LibraryFile): string {
return this.folderPath ? this.folderPath + '/' + file.name : file.name;
}
thumbUrl(file: LibraryFile): string {
return this.downloads.libraryThumbUrl(this.filePath(file));
}
fileTime(file: LibraryFile): string {
return relativeTime(file.mtime * 1e9);
}
trackByName(_: number, file: LibraryFile): string {
return file.name;
}
}

View File

@ -9,25 +9,30 @@
<div class="nav-section">
<div class="nav-label">状态</div>
<button class="nav-item" [class.active]="status === 'downloading'" (click)="selectStatus('downloading')">
<button class="nav-item" [class.active]="mode === 'downloads' && status === 'downloading'" (click)="selectStatus('downloading')">
<fa-icon [icon]="faDownload" class="nav-icon"></fa-icon>
<span>正在下载</span>
<span class="dot" *ngIf="statusCounts && statusCounts.downloading > 0"></span>
<span class="nav-count num">{{ statusCounts ? statusCounts.downloading : 0 }}</span>
</button>
<button class="nav-item" [class.active]="status === 'queued'" (click)="selectStatus('queued')">
<button class="nav-item" [class.active]="mode === 'downloads' && status === 'queued'" (click)="selectStatus('queued')">
<fa-icon [icon]="faClock" class="nav-icon"></fa-icon>
<span>排队中</span>
<span class="nav-count num">{{ statusCounts ? statusCounts.queued : 0 }}</span>
</button>
<button class="nav-item" [class.active]="status === 'completed'" (click)="selectStatus('completed')">
<button class="nav-item" [class.active]="mode === 'downloads' && status === 'completed'" (click)="selectStatus('completed')">
<fa-icon [icon]="faCheckCircle" class="nav-icon"></fa-icon>
<span>已完成</span>
<span class="nav-count num">{{ statusCounts ? statusCounts.completed : 0 }}</span>
</button>
<button class="nav-item" *ngIf="libraryEnabled" [class.active]="mode === 'library'" (click)="selectLibrary()">
<fa-icon [icon]="faPhotoVideo" class="nav-icon"></fa-icon>
<span>媒体库</span>
<span class="nav-count num">{{ libraryTotal }}</span>
</button>
</div>
<div class="nav-section">
<div class="nav-section" *ngIf="mode === 'downloads'">
<div class="nav-label">文件夹</div>
<button class="nav-item" [class.active]="selectedFolder === 'all'" (click)="selectFolder('all')">
<fa-icon [icon]="faHome" class="nav-icon"></fa-icon>
@ -41,6 +46,20 @@
</button>
</div>
<div class="nav-section" *ngIf="mode === 'library'">
<div class="nav-label">媒体库文件夹</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>
<div class="sidebar-foot">
<button class="ghost-btn" (click)="openAdvanced.emit()" title="高级选项">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>

View File

@ -1,5 +1,5 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { faCheck, faClock, faDownload, faFolderOpen, faHome } from '@fortawesome/free-solid-svg-icons';
import { faCheck, faClock, faDownload, faFolderOpen, faHome, faPhotoVideo } from '@fortawesome/free-solid-svg-icons';
import { faCheckCircle } from '@fortawesome/free-regular-svg-icons';
import { Theme } from './theme';
@ -24,9 +24,16 @@ export class SidebarComponent {
@Input() themes: Theme[] = [];
@Input() activeTheme: Theme | null = null;
@Input() open = false;
@Input() mode: 'downloads' | 'library' = 'downloads';
@Input() libraryEnabled = false;
@Input() libraryFolders: FolderNav[] = [];
@Input() libraryTotal = 0;
@Input() selectedLibraryFolder = '';
@Output() statusChange = new EventEmitter<string>();
@Output() folderChange = new EventEmitter<string>();
@Output() modeChange = new EventEmitter<'downloads' | 'library'>();
@Output() libraryFolderChange = new EventEmitter<string>();
@Output() openAdvanced = new EventEmitter<void>();
@Output() openActivity = new EventEmitter<void>();
@Output() themeChanged = new EventEmitter<Theme>();
@ -38,11 +45,20 @@ export class SidebarComponent {
faFolderOpen = faFolderOpen;
faHome = faHome;
faCheck = faCheck;
faPhotoVideo = faPhotoVideo;
folderLabel(name: string): string {
return name || '未分类';
}
get libraryRootCount(): number {
return this.libraryFolders.find(f => f.name === '')?.count || 0;
}
get libraryFoldersNonRoot(): FolderNav[] {
return this.libraryFolders.filter(f => f.name !== '');
}
selectStatus(status: string): void {
this.statusChange.emit(status);
this.close.emit();
@ -52,4 +68,14 @@ export class SidebarComponent {
this.folderChange.emit(folder);
this.close.emit();
}
selectLibrary(): void {
this.modeChange.emit('library');
this.close.emit();
}
selectLibraryFolder(folder: string): void {
this.libraryFolderChange.emit(folder);
this.close.emit();
}
}