diff --git a/app/main.py b/app/main.py index 20c399f3..b705df42 100644 --- a/app/main.py +++ b/app/main.py @@ -5,19 +5,19 @@ import sys import asyncio from pathlib import Path -from aiohttp import web -from aiohttp.log import access_logger +from aiohttp import web # type: ignore +from aiohttp.log import access_logger # pyright: ignore[reportMissingImports] import ssl import socket -import socketio +import socketio # type: ignore import logging import json import pathlib import re -from watchfiles import DefaultFilter, Change, awatch +from watchfiles import DefaultFilter, Change, awatch # type: ignore from ytdl import DownloadQueueNotifier, DownloadQueue -from yt_dlp.version import __version__ as yt_dlp_version +from yt_dlp.version import __version__ as yt_dlp_version # type: ignore log = logging.getLogger('main') @@ -345,6 +345,57 @@ def index_redirect_root(request): def index_redirect_dir(request): return web.HTTPFound(config.URL_PREFIX) +@routes.get(config.URL_PREFIX + 'thumb') +async def thumb(request): + # Query: base=video|audio, file=, folder=, t= + base = request.query.get('base', 'video') + file = request.query.get('file') or '' + folder = request.query.get('folder', '').strip('/') + t = request.query.get('t', '1') + + # Choose base dir + base_dir = config.AUDIO_DOWNLOAD_DIR if base == 'audio' else config.DOWNLOAD_DIR + + # Build absolute path and secure it + # file is a filename relative to the download folder for that item + # folder (if any) is relative to base_dir + rel_parts = [p for p in [folder, file] if p] + abs_path = os.path.realpath(os.path.join(base_dir, *rel_parts)) + real_base = os.path.realpath(base_dir) + if not abs_path.startswith(real_base): + raise web.HTTPBadRequest(text='Invalid path') + if not os.path.exists(abs_path): + raise web.HTTPNotFound() + + # For audio, return 404 so the img error handler hides it (you’ll show a placeholder instead) + audio_exts = ('.mp3', '.m4a', '.opus', '.wav', '.flac') + if abs_path.lower().endswith(audio_exts): + raise web.HTTPNotFound() + + # Run ffmpeg to stdout (single frame, scaled to width 320) + cmd = [ + 'ffmpeg', '-hide_banner', '-loglevel', 'error', + '-ss', t, '-i', abs_path, + '-frames:v', '1', + '-vf', 'scale=320:-1', + '-f', 'mjpeg', 'pipe:1', + ] + try: + proc = await asyncio.create_subprocess_exec( + *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) + except FileNotFoundError: + raise web.HTTPServiceUnavailable(text='ffmpeg not found') + + data, err = await proc.communicate() + if proc.returncode != 0 or not data: + raise web.HTTPNotFound(text='Could not generate thumbnail') + + resp = web.Response(body=data, content_type='image/jpeg') + # Optional caching + resp.headers['Cache-Control'] = 'public, max-age=86400' + return resp + 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')) diff --git a/ui/src/app/app.component.html b/ui/src/app/app.component.html index a5361543..41355d8a 100644 --- a/ui/src/app/app.component.html +++ b/ui/src/app/app.component.html @@ -321,7 +321,17 @@ -
Completed
+
+ Completed +
+ + +
+
@@ -329,49 +339,73 @@
-
- - - - - - - - - - - - - - - - - -
- - VideoFile Size
- - -
- - +
+
+ + + + + + + + + + + + + + + + + +
+ + VideoFile Size
+ + +
+ + +
+ {{ download.value.title }} + + {{download.value.title}} +
{{download.value.msg}}
+
Error: {{download.value.error}}
+
+
+ {{ download.value.size | fileSize }} + +
+ + + + +
+
+
+
+
+
+
+
+ +
+
+ {{ download.value.title }} + {{ download.value.title }}
- {{ download.value.title }} - - {{download.value.title}} -
{{download.value.msg}}
-
Error: {{download.value.error}}
-
-
- {{ download.value.size | fileSize }} - -
+ {{ download.value.size | fileSize }} +
+ -
+
+ + + diff --git a/ui/src/app/app.component.sass b/ui/src/app/app.component.sass index 1a3e6ce2..5e651827 100644 --- a/ui/src/app/app.component.sass +++ b/ui/src/app/app.component.sass @@ -29,8 +29,11 @@ button.add-url font-weight: 300 position: relative background: var(--bs-secondary-bg) - padding: 0.5rem 0 + padding: 0.5rem 0.75rem margin-top: 3.5rem + display: flex + align-items: center + justify-content: space-between .metube-section-header:before content: "" @@ -42,6 +45,31 @@ button.add-url border-left: 9999px solid var(--bs-secondary-bg) box-shadow: 9999px 0 0 var(--bs-secondary-bg) +.metube-view-toggle .btn + min-width: 96px + font-weight: 500 + +.metube-view-toggle .btn-outline-secondary + background-color: var(--bs-body-bg) + border-color: var(--bs-border-color) + color: var(--bs-body-color) + +.metube-view-toggle .btn-outline-secondary:hover + background-color: var(--bs-secondary-bg) + +.metube-view-toggle .btn-primary + box-shadow: 0 .25rem .5rem rgba(0,0,0,.08) + +.thumb + width: 80px + object-fit: cover + border-radius: 4px + vertical-align: middle + +.thumb-grid-img + aspect-ratio: 16/9 + object-fit: cover + button:hover text-decoration: none diff --git a/ui/src/app/app.component.ts b/ui/src/app/app.component.ts index 1f9b144c..11c004b1 100644 --- a/ui/src/app/app.component.ts +++ b/ui/src/app/app.component.ts @@ -11,6 +11,7 @@ import { MasterCheckboxComponent } from './master-checkbox.component'; import { Formats, Format, Quality } from './formats'; import { Theme, Themes } from './theme'; import {KeyValue} from "@angular/common"; +import { faList, faTableCells, faGrip } from '@fortawesome/free-solid-svg-icons'; @Component({ selector: 'app-root', @@ -43,6 +44,10 @@ export class AppComponent implements AfterViewInit { ytDlpVersion: string | null = null; metubeVersion: string | null = null; isAdvancedOpen = false; + viewMode: 'list' | 'grid' = 'list'; + faList = faList; + faTableCells = faTableCells; + faGrid = faGrip; // Download metrics activeDownloads = 0; @@ -504,6 +509,43 @@ export class AppComponent implements AfterViewInit { this.isAdvancedOpen = !this.isAdvancedOpen; } + getThumbnailUrl(dl: Download): string | null { + if (this.isAudioDownload(dl)) { + // show your local placeholder for audio + return 'assets/audio-placeholder.png'; + } + // Build query: base (video/audio), folder (if any), file (the filename) + const params = new URLSearchParams(); + // Decide base by the same logic you use for buildDownloadLink + const isAudio = dl.quality === 'audio' || + (dl.format && ['mp3','m4a','opus','wav','flac'].includes(dl.format)); + params.set('base', isAudio ? 'audio' : 'video'); + if (dl.folder) params.set('folder', dl.folder); + if (dl.filename) params.set('file', dl.filename); + params.set('t', '1'); // second to seek; tweak if you like + return `thumb?${params.toString()}`; + } + + onImgError(ev: Event) { + const img = ev.target as HTMLImageElement | null; + if (img) img.style.display = 'none'; + } + + isAudioDownload(dl: Download): boolean { + const audioExts = ['.mp3','.m4a','.opus','.wav','.flac']; + if (dl.quality === 'audio') return true; + if (dl.format && ['mp3','m4a','opus','wav','flac'].includes(dl.format)) return true; + if (dl.filename) { + const lower = dl.filename.toLowerCase(); + if (audioExts.some(ext => lower.endsWith(ext))) return true; + } + return false; + } + + setView(mode: 'list' | 'grid') { + this.viewMode = mode; localStorage.setItem('completedView', mode); + } + private updateMetrics() { this.activeDownloads = Array.from(this.downloads.queue.values()).filter(d => d.status === 'downloading' || d.status === 'preparing').length; this.queuedDownloads = Array.from(this.downloads.queue.values()).filter(d => d.status === 'pending').length; diff --git a/ui/src/app/downloads.service.ts b/ui/src/app/downloads.service.ts index cf63a5ee..ecb6ca2d 100644 --- a/ui/src/app/downloads.service.ts +++ b/ui/src/app/downloads.service.ts @@ -27,6 +27,7 @@ export interface Download { filename: string; checked?: boolean; deleting?: boolean; + thumbnail?: string; } @Injectable({ diff --git a/ui/src/assets/audio-placeholder.png b/ui/src/assets/audio-placeholder.png new file mode 100644 index 00000000..c012e241 Binary files /dev/null and b/ui/src/assets/audio-placeholder.png differ