mirror of
https://github.com/xpltdco/media-rip.git
synced 2026-04-03 02:53:58 -06:00
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:
parent
3931e71af5
commit
82786be485
6 changed files with 129 additions and 32 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@ export interface UrlInfo {
|
|||
duration?: number | null
|
||||
is_audio_only: boolean
|
||||
unavailable_count?: number
|
||||
default_ext?: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue