From 82786be485b2d41c49549286842e4a3f19db6b63 Mon Sep 17 00:00:00 2001 From: xpltd Date: Thu, 19 Mar 2026 03:16:38 -0500 Subject: [PATCH] Auto format label with extension, preferences persistence, toast, full delete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto format display: - 'Auto' chip now shows detected extension: 'Auto (.webm)', 'Auto (.mp3)' - Backend guesses extension from URL domain (youtube→webm, bandcamp→mp3, soundcloud→opus, etc.) and extract_info ext field for single videos Preferences persistence: - Media type (video/audio) and output format saved to localStorage - Settings survive page refreshes and gear panel open/close Toast notifications: - Copy link shows animated toast 'Link copied to clipboard' - Toast appears at bottom center, auto-dismisses after 2s Full delete on cancel: - DELETE /downloads/{id} now removes the job from DB and deletes the file - Previously marked as 'cancelled by user' and persisted in history - Jobs dismissed with X are completely purged from the system --- backend/app/routers/downloads.py | 34 +++++++++----- backend/app/services/download.py | 23 ++++++++++ backend/tests/test_api.py | 21 ++++----- frontend/src/api/types.ts | 1 + frontend/src/components/DownloadTable.vue | 54 ++++++++++++++++++++++- frontend/src/components/UrlInput.vue | 28 ++++++++---- 6 files changed, 129 insertions(+), 32 deletions(-) diff --git a/backend/app/routers/downloads.py b/backend/app/routers/downloads.py index f723bb0..87d708e 100644 --- a/backend/app/routers/downloads.py +++ b/backend/app/routers/downloads.py @@ -8,11 +8,12 @@ DELETE /downloads/{job_id} — cancel a job from __future__ import annotations import logging +import os from fastapi import APIRouter, Depends, Request from fastapi.responses import JSONResponse -from app.core.database import get_job, get_jobs_by_session +from app.core.database import delete_job, get_job, get_jobs_by_session from app.dependencies import get_session_id from app.models.job import Job, JobCreate @@ -50,24 +51,37 @@ async def cancel_download( job_id: str, request: Request, ) -> dict: - """Cancel (mark as failed) a download job.""" + """Delete a download job and remove its file.""" logger.debug("DELETE /downloads/%s", job_id) db = request.app.state.db download_service = request.app.state.download_service - # Fetch the job first to get its session_id for the SSE broadcast + # Fetch the job first to get its session_id and filename job = await get_job(db, job_id) + if job is None: + return {"status": "not_found"} - await download_service.cancel(job_id) + # Delete the downloaded file if it exists + if job.filename: + output_dir = request.app.state.config.downloads.output_dir + filepath = os.path.join(output_dir, job.filename) + try: + if os.path.isfile(filepath): + os.remove(filepath) + logger.info("Deleted file: %s", filepath) + except OSError: + logger.warning("Failed to delete file: %s", filepath) + + # Remove job from database + await delete_job(db, job_id) # Notify any SSE clients watching this session - if job is not None: - request.app.state.broker.publish( - job.session_id, - {"event": "job_removed", "data": {"job_id": job_id}}, - ) + request.app.state.broker.publish( + job.session_id, + {"event": "job_removed", "data": {"job_id": job_id}}, + ) - return {"status": "cancelled"} + return {"status": "deleted"} @router.post("/url-info") diff --git a/backend/app/services/download.py b/backend/app/services/download.py index fff1f01..ed12622 100644 --- a/backend/app/services/download.py +++ b/backend/app/services/download.py @@ -450,6 +450,23 @@ class DownloadService: url_lower = url.lower() return any(domain in url_lower for domain in audio_domains) + @staticmethod + def _guess_ext_from_url(url: str, is_audio: bool) -> str: + """Guess the likely output extension based on the source URL.""" + url_lower = url.lower() + if "bandcamp.com" in url_lower: + return "mp3" + if "soundcloud.com" in url_lower: + return "opus" + if "youtube.com" in url_lower or "youtu.be" in url_lower: + return "opus" if is_audio else "webm" + if "vimeo.com" in url_lower: + return "mp4" + if "twitter.com" in url_lower or "x.com" in url_lower: + return "mp4" + # Fallback + return "opus" if is_audio else "webm" + async def get_url_info(self, url: str) -> dict: """Get URL metadata: title, type (single/playlist), entries.""" info = await self._loop.run_in_executor( @@ -486,6 +503,7 @@ class DownloadService: "count": len(entries), "entries": entries, "is_audio_only": domain_audio, + "default_ext": self._guess_ext_from_url(url, domain_audio), } if unavailable_count > 0: result["unavailable_count"] = unavailable_count @@ -494,12 +512,17 @@ class DownloadService: # Single video/track has_video = bool(info.get("vcodec") and info["vcodec"] != "none") is_audio_only = domain_audio or not has_video + # Detect likely file extension + ext = info.get("ext") + if not ext: + ext = self._guess_ext_from_url(url, is_audio_only) return { "type": "single", "title": info.get("title"), "duration": info.get("duration"), "is_audio_only": is_audio_only, "entries": [], + "default_ext": ext, } diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 83e872a..fce65e9 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -76,14 +76,12 @@ async def test_get_downloads_after_post(client): @pytest.mark.asyncio async def test_delete_download(client): - """POST a download, DELETE it — the endpoint returns cancelled status. + """POST a download, DELETE it — the job is fully removed from the system. - The cancel endpoint marks the job as failed in the DB, but the background - worker thread may overwrite this with 'downloading' or its own 'failed' - status depending on timing. We verify: - 1. DELETE returns 200 with ``{"status": "cancelled"}`` - 2. The job's final state is either 'failed' (cancel won the race) or - another terminal state — it's no longer 'queued'. + DELETE now removes the job from the database and deletes its file. + We verify: + 1. DELETE returns 200 with ``{"status": "deleted"}`` + 2. The job no longer appears in the downloads list """ post_resp = await client.post( "/api/downloads", @@ -94,17 +92,16 @@ async def test_delete_download(client): del_resp = await client.delete(f"/api/downloads/{job_id}") assert del_resp.status_code == 200 - assert del_resp.json()["status"] == "cancelled" + assert del_resp.json()["status"] == "deleted" - # Give the background worker time to settle so the DB isn't mid-write + # Give the background worker time to settle await asyncio.sleep(0.5) - # Verify the job exists and is no longer queued + # Verify the job is gone get_resp = await client.get("/api/downloads") jobs = get_resp.json() target = [j for j in jobs if j["id"] == job_id] - assert len(target) == 1 - assert target[0]["status"] != "queued" + assert len(target) == 0 @pytest.mark.asyncio diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 897d4a2..90dd81c 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -97,6 +97,7 @@ export interface UrlInfo { duration?: number | null is_audio_only: boolean unavailable_count?: number + default_ext?: string } /** diff --git a/frontend/src/components/DownloadTable.vue b/frontend/src/components/DownloadTable.vue index 3128f7b..07baa8c 100644 --- a/frontend/src/components/DownloadTable.vue +++ b/frontend/src/components/DownloadTable.vue @@ -10,6 +10,16 @@ const props = defineProps<{ const store = useDownloadsStore() +// Toast notification +const toast = ref(null) +let toastTimer: ReturnType | null = null + +function showToast(message: string): void { + toast.value = message + if (toastTimer) clearTimeout(toastTimer) + toastTimer = setTimeout(() => { toast.value = null }, 2000) +} + // Sort state type SortKey = 'name' | 'status' | 'progress' | 'speed' | 'eta' const sortBy = ref('name') @@ -126,9 +136,10 @@ async function copyLink(job: Job): Promise { const url = `${window.location.origin}${downloadUrl(job)}` try { await navigator.clipboard.writeText(url) + showToast('Link copied to clipboard') } catch { // Fallback — clipboard API may fail in non-secure contexts - console.warn('[DownloadTable] Clipboard write failed') + showToast('Copy failed — clipboard unavailable') } } @@ -267,6 +278,12 @@ async function clearJob(jobId: string): Promise { + + +
+ {{ toast }} +
+
@@ -518,4 +535,39 @@ async function clearJob(jobId: string): Promise { padding: var(--space-xs) var(--space-sm); } } + +/* Toast notification */ +.toast-notification { + position: fixed; + bottom: calc(var(--header-height) + var(--space-md)); + left: 50%; + transform: translateX(-50%); + background: var(--color-surface); + color: var(--color-text); + border: 1px solid var(--color-border); + padding: var(--space-sm) var(--space-lg); + border-radius: var(--radius-md); + font-size: var(--font-size-sm); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + z-index: 1000; + pointer-events: none; +} + +.toast-enter-active { + transition: all 0.2s ease-out; +} + +.toast-leave-active { + transition: all 0.15s ease-in; +} + +.toast-enter-from { + opacity: 0; + transform: translateX(-50%) translateY(8px); +} + +.toast-leave-to { + opacity: 0; + transform: translateX(-50%) translateY(-4px); +} diff --git a/frontend/src/components/UrlInput.vue b/frontend/src/components/UrlInput.vue index a6e971f..f74363b 100644 --- a/frontend/src/components/UrlInput.vue +++ b/frontend/src/components/UrlInput.vue @@ -20,8 +20,12 @@ const isLoadingInfo = ref(false) const audioLocked = ref(false) // true when source is audio-only type MediaType = 'video' | 'audio' -const mediaType = ref('video') -const outputFormat = ref('auto') +const mediaType = ref( + (localStorage.getItem('mediarip:mediaType') as MediaType) || 'video' +) +const outputFormat = ref( + localStorage.getItem('mediarip:outputFormat') || 'auto' +) const audioFormats = ['auto', 'mp3', 'wav', 'm4a', 'flac', 'opus'] as const const videoFormats = ['auto', 'mp4', 'webm'] as const @@ -30,10 +34,14 @@ const availableFormats = computed(() => mediaType.value === 'audio' ? audioFormats : videoFormats ) -// Reset output format when switching media type -watch(mediaType, () => { +// Persist preferences and reset output format when switching media type +watch(mediaType, (val) => { + localStorage.setItem('mediarip:mediaType', val) outputFormat.value = 'auto' }) +watch(outputFormat, (val) => { + localStorage.setItem('mediarip:outputFormat', val) +}) /** Whether formats have been fetched for the current URL. */ const formatsReady = computed(() => formats.value.length > 0) @@ -134,16 +142,18 @@ function toggleOptions(): void { } function formatLabel(fmt: string): string { - if (fmt === 'auto') return 'Auto' + if (fmt === 'auto') { + if (urlInfo.value?.default_ext) { + return `Auto (.${urlInfo.value.default_ext})` + } + return 'Auto' + } return fmt.toUpperCase() } function formatTooltip(fmt: string): string { if (fmt !== 'auto') return `Convert to ${fmt.toUpperCase()}` - if (mediaType.value === 'audio') { - return 'Best quality audio in its native format (usually Opus/WebM)' - } - return 'Best quality video in its native format (usually WebM/MKV)' + return '' }