From 1e9014f5698792dbbff59c9488c49288bc487b64 Mon Sep 17 00:00:00 2001 From: xpltd Date: Thu, 19 Mar 2026 06:34:08 -0500 Subject: [PATCH] Error log: failed download diagnostics for admin Backend: - New error_log table: url, domain, error, format_id, media_type, session_id, created_at - log_download_error() called when yt-dlp throws during download - GET /admin/errors returns recent entries (limit 200) - DELETE /admin/errors clears all entries - Manual purge also clears error log - Domain extracted from URL via urlparse for grouping Frontend: - New 'Errors' tab in admin panel (Sessions, Storage, Errors, Settings) - Each error entry shows: domain, timestamp, full URL, error message, format/media type metadata - Red left border + error-colored message for visual scanning - Clear Log button to wipe entries - Empty state: 'No errors logged.' Error entries contain enough context (full URL, error message, domain, format, media type) to paste into an LLM for domain-specific debugging. --- backend/app/core/database.py | 61 +++++++++++++ backend/app/routers/admin.py | 26 ++++++ backend/app/services/download.py | 13 +++ backend/app/services/purge.py | 9 +- frontend/src/components/AdminPanel.vue | 115 ++++++++++++++++++++++++- frontend/src/stores/admin.ts | 40 +++++++++ 6 files changed, 261 insertions(+), 3 deletions(-) diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 1c45740..8c23e7f 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -60,6 +60,17 @@ CREATE TABLE IF NOT EXISTS unsupported_urls ( error TEXT, created_at TEXT ); + +CREATE TABLE IF NOT EXISTS error_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + url TEXT NOT NULL, + domain TEXT, + error TEXT NOT NULL, + format_id TEXT, + media_type TEXT, + session_id TEXT, + created_at TEXT NOT NULL +); """ _INDEXES = """ @@ -334,3 +345,53 @@ async def update_session_last_seen(db: aiosqlite.Connection, session_id: str) -> (now, session_id), ) await db.commit() + + +# --------------------------------------------------------------------------- +# Error log helpers +# --------------------------------------------------------------------------- + + +async def log_download_error( + db: aiosqlite.Connection, + url: str, + error: str, + session_id: str | None = None, + format_id: str | None = None, + media_type: str | None = None, +) -> None: + """Record a failed download in the error log.""" + from urllib.parse import urlparse + + now = datetime.now(timezone.utc).isoformat() + try: + domain = urlparse(url).netloc or url[:80] + except Exception: + domain = url[:80] + + await db.execute( + """INSERT INTO error_log (url, domain, error, format_id, media_type, session_id, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)""", + (url, domain, error[:2000], format_id, media_type, session_id, now), + ) + await db.commit() + + +async def get_error_log( + db: aiosqlite.Connection, + limit: int = 100, +) -> list[dict]: + """Return recent error log entries, newest first.""" + cursor = await db.execute( + "SELECT * FROM error_log ORDER BY created_at DESC LIMIT ?", + (limit,), + ) + rows = await cursor.fetchall() + return [dict(row) for row in rows] + + +async def clear_error_log(db: aiosqlite.Connection) -> int: + """Delete all error log entries. Returns count deleted.""" + cursor = await db.execute("DELETE FROM error_log") + await db.commit() + return cursor.rowcount diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index f9ce2fb..13aff78 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -145,6 +145,32 @@ async def list_unsupported_urls( return {"items": items, "total": total, "limit": limit, "offset": offset} +@router.get("/errors") +async def get_errors( + request: Request, + _admin: str = Depends(require_admin), +) -> dict: + """Return recent download error log entries.""" + from app.core.database import get_error_log + + db = request.app.state.db + entries = await get_error_log(db, limit=200) + return {"errors": entries} + + +@router.delete("/errors") +async def clear_errors( + request: Request, + _admin: str = Depends(require_admin), +) -> dict: + """Clear all error log entries.""" + from app.core.database import clear_error_log + + db = request.app.state.db + count = await clear_error_log(db) + return {"cleared": count} + + @router.post("/purge") async def manual_purge( request: Request, diff --git a/backend/app/services/download.py b/backend/app/services/download.py index e4aba92..649af22 100644 --- a/backend/app/services/download.py +++ b/backend/app/services/download.py @@ -446,6 +446,19 @@ class DownloadService: "speed": None, "eta": None, "filename": None, "error_message": str(e)}, }) + # Log to error_log table for admin visibility + from app.core.database import log_download_error + asyncio.run_coroutine_threadsafe( + log_download_error( + self._db, + url=url, + error=str(e), + session_id=session_id, + format_id=opts.get("format"), + media_type=opts.get("_media_type"), + ), + self._loop, + ) except Exception: logger.exception("Job %s failed to update status after error", job_id) diff --git a/backend/app/services/purge.py b/backend/app/services/purge.py index 549d00d..5515566 100644 --- a/backend/app/services/purge.py +++ b/backend/app/services/purge.py @@ -102,10 +102,17 @@ async def run_purge( """ ) sessions_deleted = orphan_cursor.rowcount + + # Clear error log on manual purge + error_cursor = await db.execute("DELETE FROM error_log") + errors_cleared = error_cursor.rowcount + await db.commit() - logger.info("Purge: removed %d orphaned sessions", sessions_deleted) + logger.info("Purge: removed %d orphaned sessions, %d error log entries", + sessions_deleted, errors_cleared) else: sessions_deleted = 0 + errors_cleared = 0 # Count skipped active jobs for observability active_cursor = await db.execute( diff --git a/frontend/src/components/AdminPanel.vue b/frontend/src/components/AdminPanel.vue index 11d925e..193c510 100644 --- a/frontend/src/components/AdminPanel.vue +++ b/frontend/src/components/AdminPanel.vue @@ -9,7 +9,7 @@ import AdminLogin from './AdminLogin.vue' const store = useAdminStore() const configStore = useConfigStore() const router = useRouter() -const activeTab = ref<'sessions' | 'storage' | 'settings'>('sessions') +const activeTab = ref<'sessions' | 'storage' | 'errors' | 'settings'>('sessions') // Session expansion state const expandedSessions = ref>(new Set()) @@ -52,6 +52,7 @@ async function switchTab(tab: typeof activeTab.value) { settingsSaved.value = false if (tab === 'sessions') await store.loadSessions() if (tab === 'storage') await store.loadStorage() + if (tab === 'errors') await store.loadErrorLog() if (tab === 'settings') { try { const config = await api.getPublicConfig() @@ -178,7 +179,7 @@ function formatFilesize(bytes: number | null): string {
+ +
+
+

+ Failed downloads are logged here with enough detail to diagnose domain-specific issues. +

+ +
+
+
+
+ {{ entry.domain }} + {{ new Date(entry.created_at).toLocaleString() }} +
+
{{ entry.url }}
+
{{ entry.error }}
+
+ {{ entry.media_type }} + format: {{ entry.format_id }} +
+
+
+

No errors logged.

+
+
@@ -899,4 +932,82 @@ h3 { font-size: var(--font-size-sm); font-style: italic; } + +/* Error log */ +.error-log-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-md); + margin-bottom: var(--space-md); +} + +.btn-clear-log { + background: var(--color-bg-elevated); + color: var(--color-text-muted); + border: 1px solid var(--color-border); + padding: var(--space-xs) var(--space-md); + border-radius: var(--radius-sm); + cursor: pointer; + font-size: var(--font-size-sm); + white-space: nowrap; +} + +.btn-clear-log:hover { + color: var(--color-error); + border-color: var(--color-error); +} + +.error-log { + display: flex; + flex-direction: column; + gap: var(--space-sm); +} + +.error-entry { + background: var(--color-bg-elevated); + border: 1px solid var(--color-border); + border-left: 3px solid var(--color-error); + border-radius: var(--radius-sm); + padding: var(--space-sm) var(--space-md); +} + +.error-entry-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--space-xs); +} + +.error-domain { + font-weight: 600; + color: var(--color-text); +} + +.error-time { + font-size: var(--font-size-xs); + color: var(--color-text-muted); +} + +.error-url { + font-size: var(--font-size-sm); + color: var(--color-text-muted); + word-break: break-all; + margin-bottom: var(--space-xs); +} + +.error-message { + font-size: var(--font-size-sm); + color: var(--color-error); + white-space: pre-wrap; + word-break: break-word; +} + +.error-meta { + display: flex; + gap: var(--space-md); + margin-top: var(--space-xs); + font-size: var(--font-size-xs); + color: var(--color-text-muted); +} diff --git a/frontend/src/stores/admin.ts b/frontend/src/stores/admin.ts index fbd348c..00441f5 100644 --- a/frontend/src/stores/admin.ts +++ b/frontend/src/stores/admin.ts @@ -128,6 +128,43 @@ export const useAdminStore = defineStore('admin', () => { } } + // Error log + interface ErrorLogEntry { + id: number + url: string + domain: string | null + error: string + format_id: string | null + media_type: string | null + session_id: string | null + created_at: string + } + + const errorLog = ref([]) + + async function loadErrorLog(): Promise { + const res = await fetch('/api/admin/errors', { headers: _authHeaders() }) + if (res.ok) { + const data = await res.json() + errorLog.value = data.errors + } + } + + async function clearErrorLog(): Promise { + isLoading.value = true + try { + const res = await fetch('/api/admin/errors', { + method: 'DELETE', + headers: _authHeaders(), + }) + if (res.ok) { + errorLog.value = [] + } + } finally { + isLoading.value = false + } + } + return { username, password, @@ -137,11 +174,14 @@ export const useAdminStore = defineStore('admin', () => { storage, purgeResult, isLoading, + errorLog, login, logout, loadSessions, loadStorage, triggerPurge, updateSettings, + loadErrorLog, + clearErrorLog, } })