feat: 支持媒体库多根目录挂载与分组展示
This commit is contained in:
parent
3d2d53b307
commit
96fbd6d13e
|
|
@ -47,8 +47,8 @@ guide to switching back to the official build once the upstream fix lands.
|
|||
./build-ytdlp.sh
|
||||
|
||||
docker buildx build --platform linux/amd64 \
|
||||
--build-arg VERSION=1.27 \
|
||||
-t 192.168.2.212:3000/tigeren/metube:1.27 \
|
||||
--build-arg VERSION=1.29 \
|
||||
-t 192.168.2.212:3000/tigeren/metube:1.29 \
|
||||
--push .
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -112,3 +112,72 @@
|
|||
|
||||
- 本文档保存为仓库根的 `LIBRARY_ORGANIZE_DESIGN.md`(批准后开始实现时一并提交)。
|
||||
- 全部代码改动 + 本地容器验证。
|
||||
|
||||
## 多根目录支持(多文件夹挂载)
|
||||
|
||||
> 以下为在既有单根设计基础上的扩展,不改变既有部分;实现时与上一节内容合并生效。
|
||||
|
||||
### 目标
|
||||
|
||||
- 媒体库在 UI 上**仍只有一个入口**(侧边栏"媒体库"nav 项),不按根目录拆分多个入口。
|
||||
- Docker 可以挂载多个文件夹(多个独立目录/卷),它们都属于同一个媒体库范围。
|
||||
- 侧边栏按根目录**分组展示**:每个 root 一个可折叠的顶级分组,组内保持现有扁平列表(显示根内相对路径);不引入多级树形控件。
|
||||
|
||||
### 配置
|
||||
|
||||
- 新增 `LIBRARY_DIRS`(逗号分隔的容器内绝对路径列表),默认 `%%LIBRARY_DIR`(沿用 Config 的 `%%` 插值)。
|
||||
- 只设置 `LIBRARY_DIR` 时行为完全不变(单根、`''` = 库根)。
|
||||
- 解析后列表为空 → 功能关闭:API 400 + UI 隐藏入口。
|
||||
- 每个根目录有一个 **label**(默认取容器内路径的 basename,如 `/media/actor-a` → `actor-a`):
|
||||
- 必须匹配 `[A-Za-z0-9_-]+`;启动时校验,非法 label 直接报错。
|
||||
- label 重复时按配置顺序追加 `-2`、`-3`(确定性去重)。
|
||||
- 与 API 保留词(`folders`、`files`、`organize`、`delete`、`move`、`thumbnail`)冲突时同样加后缀,避免与动态端点/静态前缀混淆。
|
||||
|
||||
docker-compose 示例:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- /mnt/actor-a:/media/actor-a
|
||||
- /mnt/actor-b:/media/actor-b
|
||||
environment:
|
||||
- LIBRARY_DIRS=/media/actor-a,/media/actor-b
|
||||
```
|
||||
|
||||
### 文件夹命名空间(分组展示)
|
||||
|
||||
- 每个文件夹条目含三个字段:`label`(所属 root)、`name`(根内相对路径,`''` 即该 root 顶级)、`path`(完整库内路径 = `label/name`,供所有 API 使用)。label 仅存在于 API 路径,不重复显示在文件夹名上。
|
||||
- 侧边栏"媒体库文件夹"列表:每个 root 一个**分组头**(label + 该 root 视频总数 + 折叠箭头),点击箭头展开/折叠,点击分组头本身 = 选中该 root 顶级(`path='<label>'`)。组内一行一个文件夹,显示 `name`(如 `Sub/Deeper`,不再带 label 前缀),按名称排序,支持筛选。
|
||||
- 分组折叠状态为组件内存态(默认展开),不持久化。
|
||||
- 单根模式:保留 `''` = 库根(现状,向后兼容),不显示分组头,"根目录"按钮照常显示,行为与现状完全一致。
|
||||
- 多根模式:不返回 `''`;每个 label 即一个分组,侧边栏"根目录"按钮隐藏。
|
||||
|
||||
### API 调整
|
||||
|
||||
- `/library/folders`:依次扫描每个 root,返回扁平列表 `[{label, name, path, count}]`(先按 label 排序,组内按 name 排序);`path` 由后端拼接,客户端直接使用。
|
||||
- `/library/files?folder=label/rel`:第一段解析为 root label,其余部分在该 root 内按现有 realpath 前缀校验解析。**新增每条文件的 `path` 字段**(库内完整相对路径,含 label);`name` 仅用于展示,客户端不再自己拼接 `folder + '/' + name`。
|
||||
- `/library/organize`、`/library/move`:`folder` 第一段必须匹配已配置的 root label;多根模式下 `folder=''` 直接拒绝。整理面板带 root 上下文:建议文件夹跨所有 root 给出(条目带 label),新建文件夹先选目标 root、再输入组内名称,客户端拼成 `label/name` 提交。路径校验逻辑与现有 `_resolve_library_path` 一致。
|
||||
- `/library/delete`:`paths` 同样按 label 解析;空目录向上清理只到该 root 边界,不删除 root 本身。
|
||||
- `/library/thumbnail`:缓存 key 已包含 rel(`sha1('lib:' + rel)`),label 内嵌后各 root 天然隔离,无冲突。
|
||||
|
||||
### 静态播放路由
|
||||
|
||||
- 每个 root 注册一条静态路由:`library/<label>/` → 该 root(动态 `/library/*` 端点先注册、先匹配优先,无冲突)。
|
||||
- `PUBLIC_HOST_LIBRARY_URL` 前缀不变;前端播放 URL = `library/` + `label/rel` 逐段 `encodeURIComponent`。
|
||||
|
||||
### 边界与异常(多根部分)
|
||||
|
||||
- 某个 root 目录不存在:启动告警并跳过;全部缺失 = 功能关闭。
|
||||
- root 互相嵌套(一个包含另一个):启动告警,要求根目录保持不相交,避免文件夹重复出现在列表中。
|
||||
- 跨 root 移动(`/library/move`):`shutil.move` 跨文件系统可用,大文件仍在 executor 中执行。
|
||||
- `CUSTOM_DIRS_EXCLUDE_REGEX` 对每个 root 各自生效。
|
||||
|
||||
### UI 影响
|
||||
|
||||
- 媒体库入口不变(仍是一个"媒体库"nav 项)。
|
||||
- `libraryEnabled` 改为 `!!config['LIBRARY_DIRS']`(configuration socket 序列化整个 Config,`LIBRARY_DIRS` 自动下发)。
|
||||
- 侧边栏:多根模式渲染分组头 + 组内文件夹(组内显示 `name`);单根模式渲染"根目录"按钮 + 扁平列表(现状)。
|
||||
- 所有文件操作路径统一使用服务端返回的 `file.path`,不再前端拼接。
|
||||
|
||||
### 实施与版本
|
||||
|
||||
- 实现时按 AGENTS.md 版本规则 bump minor,并在同一提交更新 DEPLOY.md、README 与 docker-compose.yml 示例。
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ Certain values can be set via environment variables, using the `-e` parameter on
|
|||
* Set this to an SSD or RAM filesystem (e.g., `tmpfs`) for better performance.
|
||||
* __Note__: Using a RAM filesystem may prevent downloads from being resumed.
|
||||
* __LIBRARY_DIR__: Path to a separate media library directory (mounted into the container, e.g. `/library`). When set, the UI gains a **媒体库 (Library)** view — browse library folders, play videos in-page, delete files — and completed downloads can be **organized** (moved) into library folders, with folder search and title/uploader-based suggestions. Organized items are removed from the Completed list. Leave empty to disable the feature. Defaults to empty.
|
||||
* __LIBRARY_DIRS__: Comma-separated list of library directories when multiple mounted folders should share a single library view, e.g. `LIBRARY_DIRS=/media/actor-a,/media/actor-b`. Each directory becomes a top-level collapsible group in the Library sidebar; folder names inside each group keep their group-relative path. Defaults to the value of `LIBRARY_DIR`; if both are set, `LIBRARY_DIRS` takes precedence and `LIBRARY_DIR` is ignored.
|
||||
* __PUBLIC_HOST_LIBRARY_URL__: URL prefix under which library files are served. Defaults to `library/`.
|
||||
|
||||
### 📝 File Naming & yt-dlp
|
||||
|
|
|
|||
104
app/main.py
104
app/main.py
|
|
@ -22,7 +22,10 @@ import aiohttp
|
|||
from urllib.parse import urlparse
|
||||
from watchfiles import DefaultFilter, Change, awatch
|
||||
|
||||
from ytdl import DownloadQueueNotifier, DownloadQueue, library_meta_path
|
||||
from ytdl import (
|
||||
DownloadQueueNotifier, DownloadQueue, library_meta_path,
|
||||
build_library_roots, normalize_library_roots, resolve_library_path,
|
||||
)
|
||||
from yt_dlp.version import __version__ as yt_dlp_version
|
||||
|
||||
log = logging.getLogger('main')
|
||||
|
|
@ -39,6 +42,7 @@ class Config:
|
|||
'DELETE_FILE_ON_TRASHCAN': 'true',
|
||||
'STATE_DIR': '.',
|
||||
'LIBRARY_DIR': '',
|
||||
'LIBRARY_DIRS': '%%LIBRARY_DIR',
|
||||
'PUBLIC_HOST_LIBRARY_URL': 'library/',
|
||||
'URL_PREFIX': '',
|
||||
'PUBLIC_HOST_URL': 'download/',
|
||||
|
|
@ -80,6 +84,18 @@ class Config:
|
|||
sys.exit(1)
|
||||
setattr(self, k, v in ('true', 'True', 'on', '1'))
|
||||
|
||||
# Media library: comma-separated container paths; empty = feature off.
|
||||
# Kept as a list so the UI can check `LIBRARY_DIRS` directly.
|
||||
self.LIBRARY_DIRS = [d.strip() for d in str(self.LIBRARY_DIRS).split(',') if d.strip()]
|
||||
try:
|
||||
roots = build_library_roots(self.LIBRARY_DIRS)
|
||||
except ValueError as e:
|
||||
log.error(str(e))
|
||||
sys.exit(1)
|
||||
self.LIBRARY_ROOTS = normalize_library_roots(roots)
|
||||
if self.LIBRARY_DIRS and not self.LIBRARY_ROOTS:
|
||||
log.warning('Library feature disabled: none of LIBRARY_DIRS exists')
|
||||
|
||||
if not self.URL_PREFIX.endswith('/'):
|
||||
self.URL_PREFIX += '/'
|
||||
|
||||
|
|
@ -367,21 +383,14 @@ VIDEO_EXTENSIONS = ('.mp4', '.mkv', '.webm', '.avi', '.mov', '.m4v', '.ts', '.fl
|
|||
|
||||
|
||||
def _library_root():
|
||||
"""Realpath of the configured library directory, or None when the feature is off."""
|
||||
if not config.LIBRARY_DIR:
|
||||
return None
|
||||
return os.path.realpath(config.LIBRARY_DIR)
|
||||
"""Normalized [(label, realpath)] library roots; empty = feature off."""
|
||||
return config.LIBRARY_ROOTS
|
||||
|
||||
|
||||
def _resolve_library_path(rel):
|
||||
"""Resolve a client-supplied library-relative path. Returns (root, path);
|
||||
path is None when it would escape the library root."""
|
||||
root = _library_root()
|
||||
if root is None:
|
||||
return None, None
|
||||
path = os.path.realpath(os.path.join(root, rel or ''))
|
||||
if path != root and not path.startswith(root + os.sep):
|
||||
return root, None
|
||||
path is None when it would escape the library root(s)."""
|
||||
_, root, path = resolve_library_path(config.LIBRARY_ROOTS, rel or '')
|
||||
return root, path
|
||||
|
||||
|
||||
|
|
@ -389,16 +398,19 @@ _library_folders_cache = {'time': 0.0, 'data': None}
|
|||
_LIBRARY_FOLDERS_TTL = 60
|
||||
|
||||
|
||||
def _scan_library_folders(root):
|
||||
def _scan_library_folders(roots):
|
||||
exclude = re.compile(config.CUSTOM_DIRS_EXCLUDE_REGEX) if config.CUSTOM_DIRS_EXCLUDE_REGEX else None
|
||||
folders = []
|
||||
for dirpath, dirnames, filenames in os.walk(root):
|
||||
rel = os.path.relpath(dirpath, root)
|
||||
dirnames[:] = [d for d in dirnames
|
||||
if not (exclude and exclude.search(os.path.normpath(os.path.join(rel, d))))]
|
||||
count = sum(1 for f in filenames if os.path.splitext(f)[1].lower() in VIDEO_EXTENSIONS)
|
||||
folders.append({'name': '' if rel == '.' else rel, 'count': count})
|
||||
folders.sort(key=lambda f: f['name'].lower())
|
||||
for label, root in roots:
|
||||
for dirpath, dirnames, filenames in os.walk(root):
|
||||
rel = os.path.relpath(dirpath, root)
|
||||
dirnames[:] = [d for d in dirnames
|
||||
if not (exclude and exclude.search(os.path.normpath(os.path.join(rel, d))))]
|
||||
count = sum(1 for f in filenames if os.path.splitext(f)[1].lower() in VIDEO_EXTENSIONS)
|
||||
name = '' if rel == '.' else rel
|
||||
path = name if not label else (label + '/' + name if name else label)
|
||||
folders.append({'label': label, 'name': name, 'path': path, 'count': count})
|
||||
folders.sort(key=lambda f: (f['label'], f['name'].lower()))
|
||||
return folders
|
||||
|
||||
|
||||
|
|
@ -408,21 +420,22 @@ def _invalidate_library_folders():
|
|||
|
||||
@routes.get(config.URL_PREFIX + 'library/folders')
|
||||
async def library_folders(request):
|
||||
root = _library_root()
|
||||
if root is None:
|
||||
raise web.HTTPBadRequest(text='LIBRARY_DIR is not configured')
|
||||
roots = _library_root()
|
||||
if not roots:
|
||||
raise web.HTTPBadRequest(text='LIBRARY_DIRS is not configured')
|
||||
now = time.time()
|
||||
if _library_folders_cache['data'] is None or now - _library_folders_cache['time'] > _LIBRARY_FOLDERS_TTL:
|
||||
_library_folders_cache['data'] = await asyncio.get_running_loop().run_in_executor(None, _scan_library_folders, root)
|
||||
_library_folders_cache['data'] = await asyncio.get_running_loop().run_in_executor(None, _scan_library_folders, roots)
|
||||
_library_folders_cache['time'] = now
|
||||
return web.Response(text=serializer.encode({'folders': _library_folders_cache['data']}))
|
||||
|
||||
|
||||
@routes.get(config.URL_PREFIX + 'library/files')
|
||||
async def library_files(request):
|
||||
root, path = _resolve_library_path(request.query.get('folder', ''))
|
||||
folder = request.query.get('folder', '')
|
||||
root, path = _resolve_library_path(folder)
|
||||
if root is None:
|
||||
raise web.HTTPBadRequest(text='LIBRARY_DIR is not configured')
|
||||
raise web.HTTPBadRequest(text='LIBRARY_DIRS is not configured')
|
||||
if path is None or not os.path.isdir(path):
|
||||
raise web.HTTPBadRequest(text='Invalid library folder')
|
||||
|
||||
|
|
@ -435,7 +448,12 @@ async def library_files(request):
|
|||
if os.path.splitext(e.name)[1].lower() not in VIDEO_EXTENSIONS:
|
||||
continue
|
||||
st = e.stat()
|
||||
entry = {'name': e.name, 'size': st.st_size, 'mtime': st.st_mtime}
|
||||
entry = {
|
||||
'name': e.name,
|
||||
'path': folder + '/' + e.name if folder else e.name,
|
||||
'size': st.st_size,
|
||||
'mtime': st.st_mtime,
|
||||
}
|
||||
meta_file = library_meta_path(e.path)
|
||||
if os.path.isfile(meta_file):
|
||||
try:
|
||||
|
|
@ -472,13 +490,12 @@ async def library_delete(request):
|
|||
paths = post.get('paths')
|
||||
if not paths:
|
||||
raise web.HTTPBadRequest()
|
||||
root = _library_root()
|
||||
if root is None:
|
||||
raise web.HTTPBadRequest(text='LIBRARY_DIR is not configured')
|
||||
if not _library_root():
|
||||
raise web.HTTPBadRequest(text='LIBRARY_DIRS is not configured')
|
||||
deleted = 0
|
||||
errors = []
|
||||
for rel in paths:
|
||||
_, path = _resolve_library_path(rel)
|
||||
root, path = _resolve_library_path(rel)
|
||||
if path is None or not os.path.isfile(path):
|
||||
errors.append(f'{rel}: invalid path')
|
||||
continue
|
||||
|
|
@ -513,11 +530,11 @@ async def library_move(request):
|
|||
folder = post.get('folder', '')
|
||||
if not paths:
|
||||
raise web.HTTPBadRequest()
|
||||
root = _library_root()
|
||||
if root is None:
|
||||
raise web.HTTPBadRequest(text='LIBRARY_DIR is not configured')
|
||||
target_dir = os.path.realpath(os.path.join(root, folder or ''))
|
||||
if target_dir != root and not target_dir.startswith(root + os.sep):
|
||||
roots = _library_root()
|
||||
if not roots:
|
||||
raise web.HTTPBadRequest(text='LIBRARY_DIRS is not configured')
|
||||
_, _, target_dir = resolve_library_path(roots, folder or '')
|
||||
if target_dir is None:
|
||||
raise web.HTTPBadRequest(text='Invalid library folder')
|
||||
|
||||
def _move():
|
||||
|
|
@ -525,7 +542,7 @@ async def library_move(request):
|
|||
moved = 0
|
||||
errors = []
|
||||
for rel in paths:
|
||||
_, src = _resolve_library_path(rel)
|
||||
root, src = _resolve_library_path(rel)
|
||||
if src is None or not os.path.isfile(src):
|
||||
errors.append(f'{rel}: invalid path')
|
||||
continue
|
||||
|
|
@ -740,7 +757,8 @@ async def history(request):
|
|||
async def connect(sid, environ):
|
||||
log.info(f"Client connected: {sid}")
|
||||
await sio.emit('all', serializer.encode(dqueue.get()), to=sid)
|
||||
await sio.emit('configuration', serializer.encode(config), to=sid)
|
||||
config_payload = {k: v for k, v in config.__dict__.items() if k != 'LIBRARY_ROOTS'}
|
||||
await sio.emit('configuration', serializer.encode(config_payload), to=sid)
|
||||
if config.CUSTOM_DIRS:
|
||||
await sio.emit('custom_dirs', serializer.encode(get_custom_dirs()), to=sid)
|
||||
if config.YTDL_OPTIONS_FILE:
|
||||
|
|
@ -827,8 +845,14 @@ if config.URL_PREFIX != '/':
|
|||
def index_redirect_dir(request):
|
||||
return web.HTTPFound(config.URL_PREFIX)
|
||||
|
||||
if config.LIBRARY_DIR:
|
||||
routes.static(config.URL_PREFIX + 'library/', config.LIBRARY_DIR, show_index=False)
|
||||
if config.LIBRARY_ROOTS:
|
||||
if len(config.LIBRARY_ROOTS) == 1:
|
||||
# Legacy single-root mode: library files are served from 'library/'
|
||||
routes.static(config.URL_PREFIX + 'library/', config.LIBRARY_ROOTS[0][1], show_index=False)
|
||||
else:
|
||||
# Multi-root mode: each root gets its own mount at 'library/<label>/'
|
||||
for label, root in config.LIBRARY_ROOTS:
|
||||
routes.static(config.URL_PREFIX + 'library/' + label + '/', root, show_index=False)
|
||||
routes.static(config.URL_PREFIX + 'download/', config.DOWNLOAD_DIR, show_index=config.DOWNLOAD_DIRS_INDEXABLE)
|
||||
routes.static(config.URL_PREFIX + 'audio_download/', config.AUDIO_DOWNLOAD_DIR, show_index=config.DOWNLOAD_DIRS_INDEXABLE)
|
||||
routes.static(config.URL_PREFIX, os.path.join(config.BASE_DIR, 'ui/dist/metube/browser'))
|
||||
|
|
|
|||
90
app/ytdl.py
90
app/ytdl.py
|
|
@ -27,6 +27,84 @@ def library_meta_path(video_path):
|
|||
'Foo.mp4' -> '.Foo.mp4.metube.json' in the same directory."""
|
||||
return os.path.join(os.path.dirname(video_path), '.' + os.path.basename(video_path) + '.metube.json')
|
||||
|
||||
|
||||
# ---------- Multi-root media library helpers ----------
|
||||
|
||||
LIBRARY_LABEL_RE = re.compile(r'^[A-Za-z0-9_-]+$')
|
||||
# Labels colliding with the dynamic /library/* endpoints would shadow their routes
|
||||
LIBRARY_RESERVED_LABELS = {'folders', 'files', 'organize', 'delete', 'move', 'thumbnail'}
|
||||
|
||||
|
||||
def build_library_roots(library_dirs):
|
||||
"""Turn LIBRARY_DIRS (list of absolute container paths) into [(label, realpath), ...].
|
||||
|
||||
The label defaults to the directory basename; duplicates and reserved names
|
||||
get a deterministic '-2', '-3' suffix. Missing directories are skipped with
|
||||
a warning — an empty result disables the library feature."""
|
||||
roots = []
|
||||
used = set()
|
||||
for raw in library_dirs:
|
||||
directory = raw.strip()
|
||||
if not directory:
|
||||
continue
|
||||
real = os.path.realpath(directory)
|
||||
base = os.path.basename(real.rstrip(os.sep)) or os.path.basename(directory)
|
||||
if not LIBRARY_LABEL_RE.match(base):
|
||||
raise ValueError(
|
||||
f'Invalid library root label "{base}" derived from "{directory}"; '
|
||||
'labels must match [A-Za-z0-9_-]+')
|
||||
if not os.path.isdir(real):
|
||||
log.warning(f'Library root "{directory}" does not exist or is not a directory; skipping it')
|
||||
continue
|
||||
label = base if base not in LIBRARY_RESERVED_LABELS else f'{base}-2'
|
||||
n = 2
|
||||
while label in used:
|
||||
n += 1
|
||||
label = f'{base}-{n}'
|
||||
used.add(label)
|
||||
roots.append((label, real))
|
||||
for i, (_, root_i) in enumerate(roots):
|
||||
for _, root_j in roots[:i]:
|
||||
if root_i != root_j and (root_i.startswith(root_j + os.sep) or root_j.startswith(root_i + os.sep)):
|
||||
log.warning(
|
||||
f'Library roots overlap: "{root_i}" and "{root_j}" are nested; '
|
||||
'folders may appear duplicated in the Library view')
|
||||
return roots
|
||||
|
||||
|
||||
def normalize_library_roots(roots):
|
||||
"""Single-root mode keeps the legacy '' label so API paths/URLs stay unchanged."""
|
||||
if len(roots) == 1:
|
||||
return [('', roots[0][1])]
|
||||
return roots
|
||||
|
||||
|
||||
def resolve_library_path(roots, rel):
|
||||
"""Resolve a library-relative path (may carry a root label prefix in multi-root mode).
|
||||
|
||||
Returns (label, root, abs_path), or (None, None, None) when the feature is
|
||||
off or the path escapes its root. In single-root mode rel is label-less and
|
||||
'' means the library root itself."""
|
||||
if not roots:
|
||||
return None, None, None
|
||||
if len(roots) == 1:
|
||||
label, root = roots[0]
|
||||
path = os.path.realpath(os.path.join(root, rel or ''))
|
||||
if path != root and not path.startswith(root + os.sep):
|
||||
return None, None, None
|
||||
return label, root, path
|
||||
if not rel:
|
||||
return None, None, None
|
||||
label, sep, remainder = rel.partition('/')
|
||||
root = next((r for l, r in roots if l == label), None)
|
||||
if root is None:
|
||||
return None, None, None
|
||||
path = os.path.realpath(os.path.join(root, remainder))
|
||||
if path != root and not path.startswith(root + os.sep):
|
||||
return None, None, None
|
||||
return label, root, path
|
||||
|
||||
|
||||
class DownloadQueueNotifier:
|
||||
async def added(self, dl):
|
||||
raise NotImplementedError
|
||||
|
|
@ -983,12 +1061,12 @@ class DownloadQueue:
|
|||
async def organize_to_library(self, ids, folder):
|
||||
"""Move completed downloads' files into the media library and remove
|
||||
their done entries — the library becomes the file's only home."""
|
||||
if not self.config.LIBRARY_DIR:
|
||||
return {'status': 'error', 'msg': 'LIBRARY_DIR is not configured', 'moved': 0, 'errors': []}
|
||||
library_root = os.path.realpath(self.config.LIBRARY_DIR)
|
||||
target_dir = os.path.realpath(os.path.join(library_root, folder or ''))
|
||||
if target_dir != library_root and not target_dir.startswith(library_root + os.sep):
|
||||
return {'status': 'error', 'msg': f'Folder "{folder}" must resolve inside the library directory', 'moved': 0, 'errors': []}
|
||||
roots = self.config.LIBRARY_ROOTS
|
||||
if not roots:
|
||||
return {'status': 'error', 'msg': 'LIBRARY_DIRS is not configured', 'moved': 0, 'errors': []}
|
||||
_, _, target_dir = resolve_library_path(roots, folder or '')
|
||||
if target_dir is None:
|
||||
return {'status': 'error', 'msg': f'Folder "{folder}" must resolve inside the library directories', 'moved': 0, 'errors': []}
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
moved = 0
|
||||
errors = []
|
||||
|
|
|
|||
|
|
@ -11,10 +11,14 @@ services:
|
|||
volumes:
|
||||
- ./downloads:/downloads
|
||||
- ./metube-config:/config
|
||||
# Media library for the organize/browse feature (change the host path to your real library)
|
||||
- ./library:/library
|
||||
# Optional: mount cookies file for authenticated downloads
|
||||
# - ./cookies:/cookies:ro
|
||||
# Media library for the organize/browse feature (change the host path to your real library)
|
||||
- ./library:/library
|
||||
# Multiple library mounts share one Library view; each mount becomes a
|
||||
# top-level collapsible group. Uncomment to use instead of ./library:
|
||||
# - /mnt/media-a:/media/media-a
|
||||
# - /mnt/media-b:/media/media-b
|
||||
# Optional: mount cookies file for authenticated downloads
|
||||
# - ./cookies:/cookies:ro
|
||||
environment:
|
||||
# Basic configuration
|
||||
- UID=0
|
||||
|
|
@ -25,8 +29,10 @@ services:
|
|||
- DOWNLOAD_DIR=/downloads
|
||||
- STATE_DIR=/config
|
||||
- TEMP_DIR=/downloads
|
||||
# Media library (organize/browse feature); leave empty to disable
|
||||
- LIBRARY_DIR=/library
|
||||
# Media library (organize/browse feature); leave empty to disable
|
||||
- LIBRARY_DIR=/library
|
||||
# Multi-root alternative (overrides LIBRARY_DIR, comma-separated):
|
||||
# - LIBRARY_DIRS=/media/media-a,/media/media-b
|
||||
|
||||
# Download behavior
|
||||
- DOWNLOAD_MODE=limited
|
||||
|
|
@ -111,4 +117,4 @@ services:
|
|||
# metube-downloads:
|
||||
# driver: local
|
||||
# metube-cookies:
|
||||
# driver: local
|
||||
# driver: local
|
||||
|
|
|
|||
|
|
@ -95,6 +95,7 @@
|
|||
[files]="libraryFiles"
|
||||
[folderPath]="libraryFolder"
|
||||
[folders]="libraryFolderNames"
|
||||
[multiRoot]="libraryMultiRoot"
|
||||
[loading]="libraryLoading"
|
||||
(play)="playLibraryFile($event)"
|
||||
(del)="deleteLibraryFile($event)"
|
||||
|
|
@ -160,6 +161,12 @@
|
|||
</div>
|
||||
<div class="modal-body">
|
||||
<p>将 {{ organizeItems.length }} 个文件移动到媒体库文件夹:</p>
|
||||
<div *ngIf="libraryMultiRoot" class="mb-2">
|
||||
<label class="text-muted small mb-1 d-block">目标根目录</label>
|
||||
<select class="form-select form-select-sm" [(ngModel)]="organizeRoot" [disabled]="organizeInProgress">
|
||||
<option *ngFor="let r of libraryRootLabels" [value]="r">{{ r }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div *ngIf="organizeSuggestions.length > 0" class="mb-3">
|
||||
<div class="text-muted small mb-2">建议(点击直接整理):</div>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm me-2" *ngFor="let s of organizeSuggestions" (click)="confirmOrganize(s)" [disabled]="organizeInProgress">{{ s }}</button>
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||
libraryFiles: LibraryFile[] = [];
|
||||
libraryFolder = ''; // '' = library root
|
||||
libraryLoading = false;
|
||||
organizeRoot = '';
|
||||
|
||||
// Organize-to-library modal
|
||||
organizeOpen = false;
|
||||
|
|
@ -213,9 +214,16 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||
this.downloads.libraryFolders().subscribe({
|
||||
next: (res) => {
|
||||
this.libraryFolders = res.folders;
|
||||
this.libraryFolderNames = res.folders.map(f => f.name).filter(n => n);
|
||||
this.libraryFolderNames = res.folders.map(f => f.path).filter(p => p);
|
||||
if (!this.libraryRootLabels.includes(this.organizeRoot)) {
|
||||
this.organizeRoot = this.libraryRootLabels[0] || '';
|
||||
}
|
||||
},
|
||||
error: () => { this.libraryFolders = []; this.libraryFolderNames = []; }
|
||||
error: () => {
|
||||
this.libraryFolders = [];
|
||||
this.libraryFolderNames = [];
|
||||
this.organizeRoot = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -237,8 +245,16 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||
return this.libraryFolders.reduce((total, f) => total + f.count, 0);
|
||||
}
|
||||
|
||||
get libraryRootLabels(): string[] {
|
||||
return Array.from(new Set(this.libraryFolders.map(f => f.label).filter(l => l)));
|
||||
}
|
||||
|
||||
get libraryMultiRoot(): boolean {
|
||||
return this.libraryRootLabels.length > 1;
|
||||
}
|
||||
|
||||
libraryFilePath(file: LibraryFile): string {
|
||||
return this.libraryFolder ? this.libraryFolder + '/' + file.name : file.name;
|
||||
return file.path || (this.libraryFolder ? this.libraryFolder + '/' + file.name : file.name);
|
||||
}
|
||||
|
||||
playLibraryFile(file: LibraryFile): void {
|
||||
|
|
@ -300,6 +316,7 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||
this.organizeItems = items;
|
||||
this.organizeTarget = null;
|
||||
this.organizeInProgress = false;
|
||||
this.organizeRoot = this.libraryRootLabels[0] || '';
|
||||
this.organizeOpen = true;
|
||||
if (this.libraryFolders.length === 0) this.loadLibraryFolders();
|
||||
}
|
||||
|
|
@ -328,17 +345,22 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||
else if (uploader && (folderNorm.includes(uploader) || uploader.includes(folderNorm))) score += 50;
|
||||
const overlap = folderNorm.split(' ').filter(t => t.length > 2 && titleTokens.has(t)).length;
|
||||
score += overlap * 10;
|
||||
if (score > 0) scores.set(f.name, (scores.get(f.name) || 0) + score);
|
||||
if (score > 0) scores.set(f.path, (scores.get(f.path) || 0) + score);
|
||||
}
|
||||
}
|
||||
return Array.from(scores.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 3)
|
||||
.map(([name]) => name);
|
||||
.map(([path]) => path);
|
||||
}
|
||||
|
||||
confirmOrganize(folder: string | null): void {
|
||||
if (folder === null || this.organizeInProgress) return;
|
||||
// Multi-root mode: a bare new-folder name (ng-select addTag) is created
|
||||
// under the root picked in the modal; known paths are used as-is.
|
||||
if (this.libraryMultiRoot && folder && !folder.includes('/') && !this.libraryFolderNames.includes(folder)) {
|
||||
folder = this.organizeRoot + '/' + folder;
|
||||
}
|
||||
const ids = this.organizeItems.map(i => i.key);
|
||||
this.organizeInProgress = true;
|
||||
this.downloads.libraryOrganize(ids, folder).subscribe((res) => {
|
||||
|
|
@ -755,7 +777,8 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||
const limit = config['DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT'];
|
||||
if (limit !== '0') this.playlistItemLimit = limit;
|
||||
const wasEnabled = this.libraryEnabled;
|
||||
this.libraryEnabled = !!config['LIBRARY_DIR'];
|
||||
const dirs = config['LIBRARY_DIRS'];
|
||||
this.libraryEnabled = Array.isArray(dirs) ? dirs.length > 0 : !!dirs;
|
||||
if (!this.libraryEnabled && this.mode === 'library') this.mode = 'downloads';
|
||||
if (this.libraryEnabled && !wasEnabled && this.libraryFolders.length === 0) this.loadLibraryFolders();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@ export interface Status {
|
|||
}
|
||||
|
||||
export interface LibraryFolder {
|
||||
label: string; // root label ('' in legacy single-root mode)
|
||||
name: string;
|
||||
path: string; // full library-relative path used by all library APIs
|
||||
count: number;
|
||||
}
|
||||
|
||||
|
|
@ -23,6 +25,7 @@ export interface LibraryFileMeta {
|
|||
}
|
||||
|
||||
export interface LibraryFile {
|
||||
path: string;
|
||||
name: string;
|
||||
size: number;
|
||||
mtime: number;
|
||||
|
|
|
|||
|
|
@ -102,12 +102,14 @@
|
|||
<div class="batchbar" [class.show]="selected.size > 0">
|
||||
<span class="batch-count">已选 {{ selected.size }} 项</span>
|
||||
<div class="move-wrap">
|
||||
<button type="button" class="batch-btn" (click)="moveMenuOpen = !moveMenuOpen">
|
||||
<button type="button" class="batch-btn" (click)="toggleMoveMenu()">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
||||
移动到…
|
||||
</button>
|
||||
<div class="move-menu" *ngIf="moveMenuOpen">
|
||||
<input type="text" class="move-search" placeholder="搜索文件夹…" [(ngModel)]="moveQuery" autofocus>
|
||||
<button *ngFor="let opt of folderOptions()" type="button" class="move-item" (click)="moveTo(opt.path)">{{ opt.label }}</button>
|
||||
<button *ngIf="!folderOptions().length" type="button" class="move-item" disabled>无匹配文件夹</button>
|
||||
<div class="move-new">
|
||||
<input type="text" placeholder="新建文件夹…" [(ngModel)]="newFolder" (keydown.enter)="confirmNewFolder()">
|
||||
<button type="button" class="batch-btn small" (click)="confirmNewFolder()">创建</button>
|
||||
|
|
|
|||
|
|
@ -450,6 +450,7 @@
|
|||
|
||||
.move-item
|
||||
width: 100%
|
||||
flex-shrink: 0
|
||||
border: none
|
||||
background: none
|
||||
border-radius: 6px
|
||||
|
|
@ -467,8 +468,23 @@
|
|||
&:disabled
|
||||
color: var(--muted)
|
||||
|
||||
.move-search
|
||||
flex-shrink: 0
|
||||
border: 1px solid var(--border)
|
||||
background: var(--bg)
|
||||
border-radius: 6px
|
||||
padding: 5px 8px
|
||||
font-size: 12.5px
|
||||
color: var(--fg)
|
||||
outline: none
|
||||
margin-bottom: 4px
|
||||
|
||||
&:focus
|
||||
border-color: var(--accent)
|
||||
|
||||
.move-new
|
||||
display: flex
|
||||
flex-shrink: 0
|
||||
gap: 6px
|
||||
padding: 6px 2px 2px
|
||||
border-top: 1px solid var(--border)
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ export class LibraryBrowserComponent implements OnChanges {
|
|||
@Input() files: LibraryFile[] = [];
|
||||
@Input() folderPath = ''; // library-relative path of the current folder
|
||||
@Input() folders: string[] = [];
|
||||
@Input() multiRoot = false;
|
||||
@Input() loading = false;
|
||||
|
||||
@Output() play = new EventEmitter<LibraryFile>();
|
||||
|
|
@ -27,6 +28,7 @@ export class LibraryBrowserComponent implements OnChanges {
|
|||
view: 'list' | 'grid' = 'grid';
|
||||
selected = new Set<string>();
|
||||
moveMenuOpen = false;
|
||||
moveQuery = '';
|
||||
newFolder = '';
|
||||
|
||||
faList = faList;
|
||||
|
|
@ -62,7 +64,7 @@ export class LibraryBrowserComponent implements OnChanges {
|
|||
}
|
||||
|
||||
filePath(file: LibraryFile): string {
|
||||
return this.folderPath ? this.folderPath + '/' + file.name : file.name;
|
||||
return file.path || (this.folderPath ? this.folderPath + '/' + file.name : file.name);
|
||||
}
|
||||
|
||||
thumbUrl(file: LibraryFile): string {
|
||||
|
|
@ -106,14 +108,23 @@ export class LibraryBrowserComponent implements OnChanges {
|
|||
clearSelection(): void {
|
||||
this.selected.clear();
|
||||
this.moveMenuOpen = false;
|
||||
this.moveQuery = '';
|
||||
}
|
||||
|
||||
folderOptions(): { label: string; path: string }[] {
|
||||
const opts = [{ label: '根目录', path: '' }];
|
||||
const opts: { label: string; path: string }[] = this.multiRoot ? [] : [{ label: '根目录', path: '' }];
|
||||
[...this.folders].sort((a, b) => a.localeCompare(b, 'zh')).forEach(f => {
|
||||
if (f) opts.push({ label: f, path: f });
|
||||
});
|
||||
return opts.filter(o => o.path !== this.folderPath);
|
||||
let result = opts.filter(o => o.path !== this.folderPath);
|
||||
const q = this.moveQuery.trim().toLowerCase();
|
||||
if (q) result = result.filter(o => o.label.toLowerCase().includes(q));
|
||||
return result;
|
||||
}
|
||||
|
||||
toggleMoveMenu(): void {
|
||||
this.moveMenuOpen = !this.moveMenuOpen;
|
||||
if (!this.moveMenuOpen) this.moveQuery = '';
|
||||
}
|
||||
|
||||
moveTo(folder: string): void {
|
||||
|
|
@ -121,8 +132,17 @@ export class LibraryBrowserComponent implements OnChanges {
|
|||
this.clearSelection();
|
||||
}
|
||||
|
||||
get currentRoot(): string {
|
||||
return this.folderPath ? this.folderPath.split('/')[0] : '';
|
||||
}
|
||||
|
||||
confirmNewFolder(): void {
|
||||
const folder = this.newFolder.trim();
|
||||
let folder = this.newFolder.trim();
|
||||
// Multi-root mode: a bare name is created under the current root unless it
|
||||
// is an existing path (e.g. a root label).
|
||||
if (this.multiRoot && folder && !folder.includes('/') && !this.folders.includes(folder)) {
|
||||
folder = this.currentRoot ? this.currentRoot + '/' + folder : folder;
|
||||
}
|
||||
if (folder) this.moveTo(folder);
|
||||
this.newFolder = '';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,17 +53,42 @@
|
|||
<input type="search" placeholder="筛选文件夹…" aria-label="筛选文件夹"
|
||||
[(ngModel)]="folderFilter">
|
||||
</div>
|
||||
<button class="nav-item" [class.active]="selectedLibraryFolder === ''" (click)="selectLibraryFolder('')">
|
||||
<fa-icon [icon]="faHome" class="nav-icon"></fa-icon>
|
||||
<span>根目录</span>
|
||||
<span class="nav-count num">{{ libraryRootCount }}</span>
|
||||
</button>
|
||||
<button *ngFor="let folder of libraryFoldersNonRoot" class="nav-item" [class.active]="selectedLibraryFolder === folder.name" (click)="selectLibraryFolder(folder.name)">
|
||||
<fa-icon [icon]="faFolderOpen" class="nav-icon"></fa-icon>
|
||||
<span>{{ folder.name }}</span>
|
||||
<span class="nav-count num">{{ folder.count }}</span>
|
||||
</button>
|
||||
<div class="nav-empty" *ngIf="folderFilter && libraryFoldersNonRoot.length === 0">无匹配文件夹</div>
|
||||
<ng-container *ngIf="!multiRoot">
|
||||
<button class="nav-item" [class.active]="selectedLibraryFolder === ''" (click)="selectLibraryFolder('')">
|
||||
<fa-icon [icon]="faHome" class="nav-icon"></fa-icon>
|
||||
<span>根目录</span>
|
||||
<span class="nav-count num">{{ libraryRootCount }}</span>
|
||||
</button>
|
||||
<button *ngFor="let folder of filteredLibraryFolders" class="nav-item" [class.active]="selectedLibraryFolder === folder.path" (click)="selectLibraryFolder(folder.path)">
|
||||
<fa-icon [icon]="faFolderOpen" class="nav-icon"></fa-icon>
|
||||
<span>{{ folder.name }}</span>
|
||||
<span class="nav-count num">{{ folder.count }}</span>
|
||||
</button>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="multiRoot">
|
||||
<div class="lib-group" *ngFor="let group of libraryGroups">
|
||||
<div class="lib-group-head">
|
||||
<button type="button" class="lib-group-toggle" [class.collapsed]="isGroupCollapsed(group.label)"
|
||||
(click)="toggleGroup(group.label)"
|
||||
[attr.aria-label]="(isGroupCollapsed(group.label) ? '展开' : '折叠') + ' ' + group.label">
|
||||
<fa-icon [icon]="faChevronRight"></fa-icon>
|
||||
</button>
|
||||
<button class="nav-item" [class.active]="selectedLibraryFolder === group.label" (click)="selectLibraryFolder(group.label)">
|
||||
<fa-icon [icon]="faFolderOpen" class="nav-icon"></fa-icon>
|
||||
<span>{{ group.label }}</span>
|
||||
<span class="nav-count num">{{ group.count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<ng-container *ngIf="!isGroupCollapsed(group.label)">
|
||||
<button *ngFor="let folder of group.folders" class="nav-item lib-group-item" [class.active]="selectedLibraryFolder === folder.path" (click)="selectLibraryFolder(folder.path)">
|
||||
<fa-icon [icon]="faFolderOpen" class="nav-icon"></fa-icon>
|
||||
<span>{{ folder.name }}</span>
|
||||
<span class="nav-count num">{{ folder.count }}</span>
|
||||
</button>
|
||||
</ng-container>
|
||||
</div>
|
||||
</ng-container>
|
||||
<div class="nav-empty" *ngIf="folderFilter && libraryFilterEmpty">无匹配文件夹</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-foot">
|
||||
|
|
|
|||
|
|
@ -147,6 +147,47 @@
|
|||
flex: none
|
||||
animation: pulse 1.6s ease-in-out infinite
|
||||
|
||||
.lib-group
|
||||
margin: 1px 0
|
||||
|
||||
.lib-group-head
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: 2px
|
||||
|
||||
.nav-item
|
||||
flex: 1
|
||||
min-width: 0
|
||||
|
||||
.lib-group-toggle
|
||||
flex: none
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: center
|
||||
width: 24px
|
||||
height: 30px
|
||||
padding: 0
|
||||
border: none
|
||||
border-radius: 6px
|
||||
background: none
|
||||
color: var(--muted)
|
||||
cursor: pointer
|
||||
|
||||
&:hover
|
||||
background: var(--bg)
|
||||
color: var(--fg)
|
||||
|
||||
svg
|
||||
font-size: 11px
|
||||
transition: transform 0.15s ease
|
||||
transform: rotate(90deg)
|
||||
|
||||
&.collapsed svg
|
||||
transform: none
|
||||
|
||||
.lib-group-item
|
||||
padding-left: 30px
|
||||
|
||||
@keyframes pulse
|
||||
50%
|
||||
opacity: 0.35
|
||||
|
|
|
|||
|
|
@ -1,13 +1,20 @@
|
|||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { faCheck, faClock, faDownload, faFolderOpen, faHome, faPhotoVideo } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faCheck, faChevronRight, faClock, faDownload, faFolderOpen, faHome, faPhotoVideo } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faCheckCircle } from '@fortawesome/free-regular-svg-icons';
|
||||
import { Theme } from './theme';
|
||||
import { LibraryFolder } from './downloads.service';
|
||||
|
||||
export interface FolderNav {
|
||||
name: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface LibraryGroup {
|
||||
label: string;
|
||||
count: number;
|
||||
folders: LibraryFolder[];
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-sidebar',
|
||||
standalone: false,
|
||||
|
|
@ -26,7 +33,7 @@ export class SidebarComponent {
|
|||
@Input() open = false;
|
||||
@Input() mode: 'downloads' | 'library' = 'downloads';
|
||||
@Input() libraryEnabled = false;
|
||||
@Input() libraryFolders: FolderNav[] = [];
|
||||
@Input() libraryFolders: LibraryFolder[] = [];
|
||||
@Input() libraryTotal = 0;
|
||||
@Input() selectedLibraryFolder = '';
|
||||
|
||||
|
|
@ -46,22 +53,66 @@ export class SidebarComponent {
|
|||
faHome = faHome;
|
||||
faCheck = faCheck;
|
||||
faPhotoVideo = faPhotoVideo;
|
||||
faChevronRight = faChevronRight;
|
||||
|
||||
folderLabel(name: string): string {
|
||||
return name || '未分类';
|
||||
}
|
||||
|
||||
folderFilter = '';
|
||||
collapsedGroups = new Set<string>();
|
||||
|
||||
get multiRoot(): boolean {
|
||||
return this.libraryFolders.some(f => f.label);
|
||||
}
|
||||
|
||||
get libraryRootCount(): number {
|
||||
return this.libraryFolders.find(f => f.name === '')?.count || 0;
|
||||
}
|
||||
|
||||
get libraryFoldersNonRoot(): FolderNav[] {
|
||||
get filteredLibraryFolders(): LibraryFolder[] {
|
||||
const q = this.folderFilter.trim().toLowerCase();
|
||||
return this.libraryFolders.filter(f => f.name !== '' && (!q || f.name.toLowerCase().includes(q)));
|
||||
}
|
||||
|
||||
get libraryGroups(): LibraryGroup[] {
|
||||
const groups = new Map<string, LibraryGroup>();
|
||||
for (const f of this.libraryFolders) {
|
||||
if (!f.label) continue;
|
||||
let g = groups.get(f.label);
|
||||
if (!g) {
|
||||
g = { label: f.label, count: 0, folders: [] };
|
||||
groups.set(f.label, g);
|
||||
}
|
||||
g.count += f.count;
|
||||
if (f.name) g.folders.push(f);
|
||||
}
|
||||
let result = Array.from(groups.values());
|
||||
const q = this.folderFilter.trim().toLowerCase();
|
||||
if (q) {
|
||||
result = result
|
||||
.map(g => ({ ...g, folders: g.folders.filter(f => f.name.toLowerCase().includes(q)) }))
|
||||
.filter(g => g.label.toLowerCase().includes(q) || g.folders.length > 0);
|
||||
}
|
||||
return result.sort((a, b) => a.label.localeCompare(b.label, 'zh'));
|
||||
}
|
||||
|
||||
get libraryFilterEmpty(): boolean {
|
||||
return this.multiRoot ? this.libraryGroups.length === 0 : this.filteredLibraryFolders.length === 0;
|
||||
}
|
||||
|
||||
toggleGroup(label: string): void {
|
||||
if (this.collapsedGroups.has(label)) {
|
||||
this.collapsedGroups.delete(label);
|
||||
} else {
|
||||
this.collapsedGroups.add(label);
|
||||
}
|
||||
}
|
||||
|
||||
isGroupCollapsed(label: string): boolean {
|
||||
return this.collapsedGroups.has(label);
|
||||
}
|
||||
|
||||
selectStatus(status: string): void {
|
||||
this.statusChange.emit(status);
|
||||
this.close.emit();
|
||||
|
|
|
|||
Loading…
Reference in New Issue