feat: 新增媒体库整理与浏览功能
This commit is contained in:
parent
4a85bdfaf6
commit
3aa106f003
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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`(批准后开始实现时一并提交)。
|
||||
- 全部代码改动 + 本地容器验证。
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
160
app/main.py
160
app/main.py
|
|
@ -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'))
|
||||
|
|
|
|||
49
app/ytdl.py
49
app/ytdl.py
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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="活动日志">
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
删除
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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> 刷新
|
||||
</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>
|
||||
|
|
@ -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))
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue