From e87c927a09e37bf2e25bccb12c42ea753d46119b Mon Sep 17 00:00:00 2001 From: tigerenwork Date: Sat, 15 Aug 2026 01:24:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AA=92=E4=BD=93=E5=BA=93=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E6=89=B9=E9=87=8F=E9=80=89=E6=8B=A9=E3=80=81=E7=A7=BB?= =?UTF-8?q?=E5=8A=A8=E4=B8=8E=E5=88=A0=E9=99=A4=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DEPLOY.md | 4 +- app/main.py | 59 ++++++++ ui/src/app/app.component.html | 8 +- ui/src/app/app.component.ts | 28 +++- ui/src/app/command-bar.component.html | 6 +- ui/src/app/download-card.component.html | 9 +- ui/src/app/download-card.component.sass | 12 ++ ui/src/app/download-row.component.html | 11 +- ui/src/app/download-row.component.sass | 12 ++ ui/src/app/download-row.component.ts | 4 + ui/src/app/downloads.service.ts | 6 + ui/src/app/library-browser.component.html | 37 ++++- ui/src/app/library-browser.component.sass | 160 +++++++++++++++++++++- ui/src/app/library-browser.component.ts | 71 +++++++++- ui/src/app/sidebar.component.html | 6 + ui/src/app/sidebar.component.sass | 35 +++++ ui/src/app/sidebar.component.ts | 5 +- ui/src/styles.sass | 1 + 18 files changed, 449 insertions(+), 25 deletions(-) diff --git a/DEPLOY.md b/DEPLOY.md index abebf6c..8082a59 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -27,8 +27,8 @@ default, e.g. via QEMU emulation). ```sh docker buildx build --platform linux/amd64 \ - --build-arg VERSION=1.20 \ - -t 192.168.2.212:3000/tigeren/metube:1.20 \ + --build-arg VERSION=1.21 \ + -t 192.168.2.212:3000/tigeren/metube:1.21 \ --push . ``` diff --git a/app/main.py b/app/main.py index 5900976..383d4b2 100644 --- a/app/main.py +++ b/app/main.py @@ -4,6 +4,7 @@ import os import sys import asyncio +import shutil from pathlib import Path from aiohttp import web from aiohttp.log import access_logger @@ -505,6 +506,64 @@ async def library_delete(request): return web.Response(text=serializer.encode({'status': 'ok', 'deleted': deleted, 'errors': errors})) +@routes.post(config.URL_PREFIX + 'library/move') +async def library_move(request): + post = await request.json() + paths = post.get('paths') + 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): + raise web.HTTPBadRequest(text='Invalid library folder') + + def _move(): + os.makedirs(target_dir, exist_ok=True) + moved = 0 + errors = [] + for rel in paths: + _, src = _resolve_library_path(rel) + if src is None or not os.path.isfile(src): + errors.append(f'{rel}: invalid path') + continue + if os.path.realpath(os.path.dirname(src)) == target_dir: + continue + dst = os.path.join(target_dir, os.path.basename(src)) + if os.path.exists(dst): + errors.append(f'{rel}: target already exists') + continue + try: + shutil.move(src, dst) + except OSError as e: + errors.append(f'{rel}: {e}') + continue + meta_src = library_meta_path(src) + if os.path.isfile(meta_src): + try: + shutil.move(meta_src, library_meta_path(dst)) + except OSError: + pass + moved += 1 + # Clean up emptied parent directories, never the library root itself + parent = os.path.dirname(src) + while parent != root and parent.startswith(root + os.sep): + try: + os.rmdir(parent) + except OSError: + break + parent = os.path.dirname(parent) + return moved, errors + + moved, errors = await asyncio.get_running_loop().run_in_executor(None, _move) + if moved: + _invalidate_library_folders() + log.info(f"Library move request processed: moved={moved} errors={len(errors)}") + return web.Response(text=serializer.encode({'status': 'ok', 'moved': moved, '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.""" diff --git a/ui/src/app/app.component.html b/ui/src/app/app.component.html index 3136496..9505f70 100644 --- a/ui/src/app/app.component.html +++ b/ui/src/app/app.component.html @@ -94,9 +94,12 @@ @@ -169,7 +172,10 @@ [searchable]="true" [clearable]="true" appendTo="body"> - + + {{ item }} + + + @@ -40,12 +41,13 @@
-
-
+
+ +
-
{{ file.name }}
+
{{ file.name }}
{{ file.size | fileSize }} {{ fileTime(file) }} @@ -66,12 +68,13 @@
-
-
+
+ +
-
{{ file.name }}
+
{{ file.name }}
{{ file.size | fileSize }} {{ fileTime(file) }} @@ -91,4 +94,26 @@
+ +
+ 已选 {{ selected.size }} 项 +
+ +
+ +
+ + +
+
+
+ + +
diff --git a/ui/src/app/library-browser.component.sass b/ui/src/app/library-browser.component.sass index 1053b3b..5b0336c 100644 --- a/ui/src/app/library-browser.component.sass +++ b/ui/src/app/library-browser.component.sass @@ -180,15 +180,19 @@ .lib-row display: grid - grid-template-columns: 88px 1fr auto + grid-template-columns: 30px 88px 1fr auto gap: 12px align-items: center - padding: 10px 14px + padding: 10px 14px 10px 8px border-bottom: 1px solid var(--border) + transition: background 0.1s ease &:last-child border-bottom: none + &.selected + background: var(--accent-tint) + .thumb width: 88px aspect-ratio: 16 / 9 @@ -202,6 +206,13 @@ height: 100% object-fit: cover +.lib-check + width: 17px + height: 17px + accent-color: var(--accent) + cursor: pointer + margin-left: 6px + .lib-main min-width: 0 @@ -213,6 +224,18 @@ overflow: hidden text-overflow: ellipsis +.clickable + cursor: pointer + +.lib-title.clickable:hover, .card-title.clickable:hover + color: var(--accent-strong) + +.thumb.clickable img + transition: opacity 0.12s ease + +.thumb.clickable:hover img + opacity: 0.85 + .lib-meta display: flex gap: 10px @@ -244,12 +267,17 @@ border: 1px solid var(--border) border-radius: var(--radius) overflow: hidden + position: relative 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) + &.selected + border-color: var(--accent) + box-shadow: 0 0 0 1px var(--accent) + .thumb aspect-ratio: 16 / 9 overflow: hidden @@ -262,6 +290,21 @@ height: 100% object-fit: cover +.card-check + position: absolute + top: 8px + left: 8px + z-index: 2 + width: 17px + height: 17px + accent-color: var(--accent) + cursor: pointer + opacity: 0 + transition: opacity 0.12s ease + +.card:hover .card-check, .card-check:checked + opacity: 1 + .card-body padding: 10px 12px 12px @@ -314,6 +357,119 @@ background: var(--bg) color: var(--fg) +// Batch bar +.batchbar + position: fixed + left: 50% + bottom: 18px + transform: translate(-50%, 76px) + display: flex + align-items: center + gap: 6px + background: var(--fg) + color: oklch(96% 0.008 240) + border-radius: 10px + padding: 8px 8px 8px 16px + box-shadow: 0 10px 30px oklch(20% 0.02 240 / 0.3) + transition: transform 0.2s cubic-bezier(0.2, 0.9, 0.3, 1.2) + z-index: 30 + pointer-events: none + + &.show + transform: translate(-50%, 0) + pointer-events: auto + +.batch-count + font-size: 13px + font-weight: 550 + margin-right: 8px + white-space: nowrap + +.batch-btn + display: inline-flex + align-items: center + gap: 6px + border: none + background: oklch(100% 0 0 / 0.1) + color: oklch(96% 0.008 240) + border-radius: 6px + padding: 7px 12px + font-size: 12.5px + font-weight: 500 + transition: background 0.12s ease + white-space: nowrap + + &:hover + background: oklch(100% 0 0 / 0.2) + + &.danger:hover + background: var(--danger) + + &.small + padding: 6px 10px + font-size: 12px + +.move-wrap + position: relative + +.move-menu + position: absolute + bottom: calc(100% + 8px) + left: 0 + z-index: 35 + min-width: 190px + max-height: 320px + overflow-y: auto + background: var(--surface) + border: 1px solid var(--border) + border-radius: 8px + box-shadow: 0 8px 24px oklch(30% 0.02 240 / 0.18) + padding: 6px + display: flex + flex-direction: column + gap: 2px + color: var(--fg) + + .move-item + width: 100% + border: none + background: none + border-radius: 6px + padding: 7px 10px + font-size: 12.5px + color: var(--fg) + text-align: left + white-space: nowrap + overflow: hidden + text-overflow: ellipsis + + &:hover:not(:disabled) + background: var(--bg) + + &:disabled + color: var(--muted) + + .move-new + display: flex + gap: 6px + padding: 6px 2px 2px + border-top: 1px solid var(--border) + margin-top: 4px + + input + flex: 1 + min-width: 0 + border: 1px solid var(--border) + background: var(--bg) + border-radius: 6px + padding: 5px 8px + font-size: 12.5px + color: var(--fg) + outline: none + + &:focus + border-color: var(--accent) + @media (max-width: 900px) .filter-wrap width: 100% diff --git a/ui/src/app/library-browser.component.ts b/ui/src/app/library-browser.component.ts index 631cbd5..5f7f350 100644 --- a/ui/src/app/library-browser.component.ts +++ b/ui/src/app/library-browser.component.ts @@ -1,4 +1,4 @@ -import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core'; import { faExternalLinkAlt, faList, faPlay, faRedoAlt, faThLarge, faTrashAlt } from '@fortawesome/free-solid-svg-icons'; import { DownloadsService, LibraryFile } from './downloads.service'; import { relativeTime } from './labels'; @@ -9,19 +9,25 @@ import { relativeTime } from './labels'; templateUrl: './library-browser.component.html', styleUrls: ['./library-browser.component.sass'] }) -export class LibraryBrowserComponent { +export class LibraryBrowserComponent implements OnChanges { @Input() files: LibraryFile[] = []; @Input() folderPath = ''; // library-relative path of the current folder + @Input() folders: string[] = []; @Input() loading = false; @Output() play = new EventEmitter(); @Output() del = new EventEmitter(); @Output() refresh = new EventEmitter(); + @Output() batchDelete = new EventEmitter(); + @Output() batchMove = new EventEmitter<{ paths: string[]; folder: string }>(); filter = ''; sort: 'name' | 'date' | 'size' = 'name'; sortAscending = true; view: 'list' | 'grid' = 'grid'; + selected = new Set(); + moveMenuOpen = false; + newFolder = ''; faList = faList; faThLarge = faThLarge; @@ -32,6 +38,12 @@ export class LibraryBrowserComponent { constructor(public downloads: DownloadsService) {} + ngOnChanges(changes: SimpleChanges): void { + if (changes['folderPath']) { + this.clearSelection(); + } + } + get filteredFiles(): LibraryFile[] { let files = this.files; const q = this.filter.trim().toLowerCase(); @@ -63,4 +75,59 @@ export class LibraryBrowserComponent { trackByName(_: number, file: LibraryFile): string { return file.name; } + + isSelected(path: string): boolean { + return this.selected.has(path); + } + + toggleSelect(path: string, checked: boolean): void { + if (checked) { + this.selected.add(path); + } else { + this.selected.delete(path); + } + this.moveMenuOpen = false; + } + + allSelected(): boolean { + return this.filteredFiles.length > 0 && this.filteredFiles.every(f => this.selected.has(this.filePath(f))); + } + + toggleSelectAll(): void { + if (this.allSelected()) { + this.filteredFiles.forEach(f => this.selected.delete(this.filePath(f))); + } else { + this.filteredFiles.forEach(f => this.selected.add(this.filePath(f))); + } + this.moveMenuOpen = false; + } + + clearSelection(): void { + this.selected.clear(); + this.moveMenuOpen = false; + } + + folderOptions(): { label: string; path: string }[] { + const opts = [{ 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); + } + + moveTo(folder: string): void { + this.batchMove.emit({ paths: Array.from(this.selected), folder }); + this.clearSelection(); + } + + confirmNewFolder(): void { + const folder = this.newFolder.trim(); + if (folder) this.moveTo(folder); + this.newFolder = ''; + } + + delSelected(): void { + this.batchDelete.emit(Array.from(this.selected)); + this.clearSelection(); + } } diff --git a/ui/src/app/sidebar.component.html b/ui/src/app/sidebar.component.html index 31615ab..e88f22b 100644 --- a/ui/src/app/sidebar.component.html +++ b/ui/src/app/sidebar.component.html @@ -48,6 +48,11 @@