Auto format label with extension, preferences persistence, toast, full delete

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
This commit is contained in:
xpltd 2026-03-19 03:16:38 -05:00
parent 3931e71af5
commit 82786be485
6 changed files with 129 additions and 32 deletions

View file

@ -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")

View file

@ -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,
}

View file

@ -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

View file

@ -97,6 +97,7 @@ export interface UrlInfo {
duration?: number | null
is_audio_only: boolean
unavailable_count?: number
default_ext?: string
}
/**

View file

@ -10,6 +10,16 @@ const props = defineProps<{
const store = useDownloadsStore()
// Toast notification
const toast = ref<string | null>(null)
let toastTimer: ReturnType<typeof setTimeout> | 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<SortKey>('name')
@ -126,9 +136,10 @@ async function copyLink(job: Job): Promise<void> {
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<void> {
</tr>
</TransitionGroup>
</table>
<!-- Toast notification -->
<Transition name="toast">
<div v-if="toast" class="toast-notification">
{{ toast }}
</div>
</Transition>
</div>
</template>
@ -518,4 +535,39 @@ async function clearJob(jobId: string): Promise<void> {
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);
}
</style>

View file

@ -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<MediaType>('video')
const outputFormat = ref<string>('auto')
const mediaType = ref<MediaType>(
(localStorage.getItem('mediarip:mediaType') as MediaType) || 'video'
)
const outputFormat = ref<string>(
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 ''
}
</script>