From 0d9e6b18acdca4379a84856f3eecbc8e38522421 Mon Sep 17 00:00:00 2001 From: xpltd Date: Thu, 19 Mar 2026 02:32:14 -0500 Subject: [PATCH] M002/S04: URL preview, playlist support, admin improvements, UX polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit URL preview & playlist support: - POST /url-info endpoint extracts metadata (title, type, entry count) - Preview box shows playlist contents before downloading (up to 10 items) - Auto-detect audio-only sources (SoundCloud, etc) and switch to Audio mode - Video toggle grayed out for audio-only sources - Enable playlist downloading (noplaylist=False) Admin panel improvements: - Expandable session rows show per-session job list with filename, size, status, timestamp, and source URL link - GET /admin/sessions/{id}/jobs endpoint for session job details - Logout now redirects to home page instead of staying on login form - Logo in header is clickable → navigates to home UX polish: - Tooltips on output format chips (explains Auto vs specific formats) - Format tooltips change based on video/audio mode --- backend/app/routers/admin.py | 35 +++++ backend/app/routers/downloads.py | 13 ++ backend/app/services/download.py | 61 ++++++++ frontend/src/api/client.ts | 10 +- frontend/src/api/types.ts | 15 ++ frontend/src/components/AdminPanel.vue | 174 ++++++++++++++++++++++- frontend/src/components/AppHeader.vue | 11 +- frontend/src/components/UrlInput.vue | 188 ++++++++++++++++++++++++- frontend/src/stores/admin.ts | 1 + 9 files changed, 495 insertions(+), 13 deletions(-) diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index b559daa..2ae2cbd 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -43,6 +43,41 @@ async def list_sessions( return {"sessions": sessions, "total": len(sessions)} +@router.get("/sessions/{session_id}/jobs") +async def session_jobs( + session_id: str, + request: Request, + _admin: str = Depends(require_admin), +) -> dict: + """List jobs for a specific session with file details.""" + db = request.app.state.db + cursor = await db.execute( + """ + SELECT id, url, status, filename, filesize, + created_at, started_at, completed_at + FROM jobs + WHERE session_id = ? + ORDER BY created_at DESC + """, + (session_id,), + ) + rows = await cursor.fetchall() + jobs = [ + { + "id": row["id"], + "url": row["url"], + "status": row["status"], + "filename": row["filename"], + "filesize": row["filesize"], + "created_at": row["created_at"], + "started_at": row["started_at"], + "completed_at": row["completed_at"], + } + for row in rows + ] + return {"jobs": jobs} + + @router.get("/storage") async def storage_info( request: Request, diff --git a/backend/app/routers/downloads.py b/backend/app/routers/downloads.py index 51df2cc..f723bb0 100644 --- a/backend/app/routers/downloads.py +++ b/backend/app/routers/downloads.py @@ -68,3 +68,16 @@ async def cancel_download( ) return {"status": "cancelled"} + + +@router.post("/url-info") +async def url_info( + request: Request, + body: dict, +) -> dict: + """Extract metadata about a URL (title, playlist detection, audio-only detection).""" + url = body.get("url", "").strip() + if not url: + return JSONResponse(status_code=400, content={"detail": "URL required"}) + download_service = request.app.state.download_service + return await download_service.get_url_info(url) diff --git a/backend/app/services/download.py b/backend/app/services/download.py index 00b5c54..6a7d802 100644 --- a/backend/app/services/download.py +++ b/backend/app/services/download.py @@ -113,6 +113,7 @@ class DownloadService: "quiet": True, "no_warnings": True, "noprogress": True, + "noplaylist": False, } if job_create.format_id: opts["format"] = job_create.format_id @@ -378,6 +379,66 @@ class DownloadService: logger.exception("Format extraction failed for %s", url) return None + def _extract_url_info(self, url: str) -> dict | None: + """Extract URL metadata including playlist detection.""" + opts = { + "quiet": True, + "no_warnings": True, + "skip_download": True, + "extract_flat": "in_playlist", + "noplaylist": False, + } + try: + with yt_dlp.YoutubeDL(opts) as ydl: + return ydl.extract_info(url, download=False) + except Exception: + logger.exception("URL info extraction failed for %s", url) + return None + + async def get_url_info(self, url: str) -> dict: + """Get URL metadata: title, type (single/playlist), entries.""" + info = await self._loop.run_in_executor( + self._executor, + self._extract_url_info, + url, + ) + if not info: + return {"type": "unknown", "title": None, "entries": []} + + result_type = info.get("_type", "video") + if result_type == "playlist" or "entries" in info: + entries_raw = info.get("entries") or [] + entries = [] + for e in entries_raw: + if isinstance(e, dict): + entries.append({ + "title": e.get("title") or e.get("id", "Unknown"), + "url": e.get("url") or e.get("webpage_url", ""), + "duration": e.get("duration"), + }) + # Detect audio-only source (no video formats) + is_audio_only = False + if info.get("categories"): + is_audio_only = "Music" in info["categories"] + return { + "type": "playlist", + "title": info.get("title", "Playlist"), + "count": len(entries), + "entries": entries, + "is_audio_only": is_audio_only, + } + else: + # Single video/track + has_video = bool(info.get("vcodec") and info["vcodec"] != "none") + is_audio_only = not has_video + return { + "type": "single", + "title": info.get("title"), + "duration": info.get("duration"), + "is_audio_only": is_audio_only, + "entries": [], + } + # --------------------------------------------------------------------------- # Helpers diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 60cdfd0..c3c6987 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -6,7 +6,7 @@ * relative paths work without configuration. */ -import type { Job, JobCreate, FormatInfo, PublicConfig, HealthStatus } from './types' +import type { Job, JobCreate, FormatInfo, PublicConfig, HealthStatus, UrlInfo } from './types' class ApiError extends Error { constructor( @@ -77,6 +77,14 @@ export const api = { async getHealth(): Promise { return request('/api/health') }, + + /** Get URL metadata (title, playlist detection, audio-only detection). */ + async getUrlInfo(url: string): Promise { + return request('/api/url-info', { + method: 'POST', + body: JSON.stringify({ url }), + }) + }, } export { ApiError } diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index c97e0c1..70428d2 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -82,6 +82,21 @@ export interface HealthStatus { queue_depth: number } +export interface UrlInfoEntry { + title: string + url: string + duration: number | null +} + +export interface UrlInfo { + type: 'single' | 'playlist' | 'unknown' + title: string | null + count?: number + entries: UrlInfoEntry[] + duration?: number | null + is_audio_only: boolean +} + /** * SSE event types received from GET /api/events. */ diff --git a/frontend/src/components/AdminPanel.vue b/frontend/src/components/AdminPanel.vue index 5e9c9c1..4f3587c 100644 --- a/frontend/src/components/AdminPanel.vue +++ b/frontend/src/components/AdminPanel.vue @@ -1,12 +1,19 @@