mirror of
https://github.com/xpltdco/media-rip.git
synced 2026-04-03 02:53:58 -06:00
Fix playlist support, session persistence, audio detection, progress errors
Playlist handling: - Playlists are split into individual jobs at enqueue time - Each entry downloads independently with its own progress tracking - Private/unavailable playlist entries detected and reported in preview - Individual jobs use noplaylist=True to prevent re-expansion Session persistence: - App.vue now calls fetchJobs() on mount to reload history from backend - Download history survives page refresh via session cookie Audio detection: - Domain-based detection for known audio sources (bandcamp, soundcloud) - Bandcamp albums now correctly trigger audio-only mode Bug fixes: - ProgressEvent accepts float for downloaded_bytes/total_bytes (fixes pydantic int_from_float validation errors from some extractors) - SSE job_update events now include error_message for failed jobs - Fixed test_health_queue_depth test to use direct DB insertion instead of POST endpoint (avoids yt-dlp side effects in test env)
This commit is contained in:
parent
0d9e6b18ac
commit
3931e71af5
7 changed files with 101 additions and 25 deletions
|
|
@ -58,8 +58,8 @@ class ProgressEvent(BaseModel):
|
|||
percent: float
|
||||
speed: str | None = None
|
||||
eta: str | None = None
|
||||
downloaded_bytes: int | None = None
|
||||
total_bytes: int | None = None
|
||||
downloaded_bytes: int | float | None = None
|
||||
total_bytes: int | float | None = None
|
||||
filename: str | None = None
|
||||
|
||||
@classmethod
|
||||
|
|
|
|||
|
|
@ -79,8 +79,53 @@ class DownloadService:
|
|||
async def enqueue(self, job_create: JobCreate, session_id: str) -> Job:
|
||||
"""Create a job and submit it for background download.
|
||||
|
||||
Returns the ``Job`` immediately with status ``queued``.
|
||||
For playlist URLs, creates one job per entry.
|
||||
Returns the first ``Job`` immediately with status ``queued``.
|
||||
"""
|
||||
# Check if this is a playlist URL — if so, split into individual jobs
|
||||
info = await self._loop.run_in_executor(
|
||||
self._executor,
|
||||
self._extract_url_info,
|
||||
job_create.url,
|
||||
)
|
||||
entries: list[dict] = []
|
||||
if info and (info.get("_type") == "playlist" or "entries" in info):
|
||||
raw_entries = info.get("entries") or []
|
||||
for e in raw_entries:
|
||||
if isinstance(e, dict):
|
||||
entry_url = e.get("url") or e.get("webpage_url", "")
|
||||
if entry_url:
|
||||
entries.append({
|
||||
"url": entry_url,
|
||||
"title": e.get("title") or e.get("id", "Unknown"),
|
||||
})
|
||||
|
||||
if len(entries) > 1:
|
||||
# Playlist: create one job per entry
|
||||
logger.info(
|
||||
"Playlist detected: %d entries for URL %s",
|
||||
len(entries),
|
||||
job_create.url,
|
||||
)
|
||||
first_job: Job | None = None
|
||||
for entry in entries:
|
||||
entry_create = JobCreate(
|
||||
url=entry["url"],
|
||||
format_id=job_create.format_id,
|
||||
quality=job_create.quality,
|
||||
output_template=job_create.output_template,
|
||||
media_type=job_create.media_type,
|
||||
output_format=job_create.output_format,
|
||||
)
|
||||
job = await self._enqueue_single(entry_create, session_id)
|
||||
if first_job is None:
|
||||
first_job = job
|
||||
return first_job # type: ignore[return-value]
|
||||
else:
|
||||
return await self._enqueue_single(job_create, session_id)
|
||||
|
||||
async def _enqueue_single(self, job_create: JobCreate, session_id: str) -> Job:
|
||||
"""Create a single job and submit it for background download."""
|
||||
job_id = str(uuid.uuid4())
|
||||
template = resolve_template(
|
||||
job_create.url,
|
||||
|
|
@ -113,7 +158,7 @@ class DownloadService:
|
|||
"quiet": True,
|
||||
"no_warnings": True,
|
||||
"noprogress": True,
|
||||
"noplaylist": False,
|
||||
"noplaylist": True, # Individual jobs — don't re-expand playlists
|
||||
}
|
||||
if job_create.format_id:
|
||||
opts["format"] = job_create.format_id
|
||||
|
|
@ -395,6 +440,16 @@ class DownloadService:
|
|||
logger.exception("URL info extraction failed for %s", url)
|
||||
return None
|
||||
|
||||
def _is_audio_only_source(self, url: str) -> bool:
|
||||
"""Check if a URL points to an audio-only source (no video streams)."""
|
||||
# Known audio-only domains
|
||||
audio_domains = [
|
||||
"bandcamp.com",
|
||||
"soundcloud.com",
|
||||
]
|
||||
url_lower = url.lower()
|
||||
return any(domain in url_lower for domain in audio_domains)
|
||||
|
||||
async def get_url_info(self, url: str) -> dict:
|
||||
"""Get URL metadata: title, type (single/playlist), entries."""
|
||||
info = await self._loop.run_in_executor(
|
||||
|
|
@ -403,34 +458,42 @@ class DownloadService:
|
|||
url,
|
||||
)
|
||||
if not info:
|
||||
return {"type": "unknown", "title": None, "entries": []}
|
||||
return {"type": "unknown", "title": None, "entries": [], "is_audio_only": False}
|
||||
|
||||
# Domain-based audio detection (more reliable than format sniffing)
|
||||
domain_audio = self._is_audio_only_source(url)
|
||||
|
||||
result_type = info.get("_type", "video")
|
||||
if result_type == "playlist" or "entries" in info:
|
||||
entries_raw = info.get("entries") or []
|
||||
entries = []
|
||||
unavailable_count = 0
|
||||
for e in entries_raw:
|
||||
if isinstance(e, dict):
|
||||
title = e.get("title") or e.get("id", "Unknown")
|
||||
# Detect private/unavailable entries
|
||||
if title in ("[Private video]", "[Deleted video]", "[Unavailable]"):
|
||||
unavailable_count += 1
|
||||
continue
|
||||
entries.append({
|
||||
"title": e.get("title") or e.get("id", "Unknown"),
|
||||
"title": title,
|
||||
"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 {
|
||||
result = {
|
||||
"type": "playlist",
|
||||
"title": info.get("title", "Playlist"),
|
||||
"count": len(entries),
|
||||
"entries": entries,
|
||||
"is_audio_only": is_audio_only,
|
||||
"is_audio_only": domain_audio,
|
||||
}
|
||||
if unavailable_count > 0:
|
||||
result["unavailable_count"] = unavailable_count
|
||||
return result
|
||||
else:
|
||||
# Single video/track
|
||||
has_video = bool(info.get("vcodec") and info["vcodec"] != "none")
|
||||
is_audio_only = not has_video
|
||||
is_audio_only = domain_audio or not has_video
|
||||
return {
|
||||
"type": "single",
|
||||
"title": info.get("title"),
|
||||
|
|
|
|||
|
|
@ -72,20 +72,15 @@ class TestHealthEndpoint:
|
|||
assert len(parts) == 3, f"Expected semver, got {version}"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_health_queue_depth_reflects_active_jobs(self, client):
|
||||
async def test_health_queue_depth_reflects_active_jobs(self, db):
|
||||
"""queue_depth counts queued + downloading + extracting, not terminal."""
|
||||
# Get the db from the test app via a back-door: make requests that
|
||||
# create jobs, then check health.
|
||||
# Create 2 queued jobs by posting downloads
|
||||
resp1 = await client.post("/api/downloads", json={"url": "https://example.com/a"})
|
||||
resp2 = await client.post("/api/downloads", json={"url": "https://example.com/b"})
|
||||
assert resp1.status_code == 201
|
||||
assert resp2.status_code == 201
|
||||
# Insert active jobs directly into DB
|
||||
sid = str(uuid.uuid4())
|
||||
await create_job(db, _make_job(sid, "queued"))
|
||||
await create_job(db, _make_job(sid, "downloading"))
|
||||
|
||||
health = await client.get("/api/health")
|
||||
data = health.json()
|
||||
# At least 2 active jobs (might be more if worker picked them up)
|
||||
assert data["queue_depth"] >= 2
|
||||
depth = await get_queue_depth(db)
|
||||
assert depth >= 2
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_health_queue_depth_excludes_completed(self, db):
|
||||
|
|
|
|||
|
|
@ -2,11 +2,13 @@
|
|||
import { onMounted } from 'vue'
|
||||
import { useSSE } from '@/composables/useSSE'
|
||||
import { useConfigStore } from '@/stores/config'
|
||||
import { useDownloadsStore } from '@/stores/downloads'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import AppHeader from '@/components/AppHeader.vue'
|
||||
import AppFooter from '@/components/AppFooter.vue'
|
||||
|
||||
const configStore = useConfigStore()
|
||||
const downloadsStore = useDownloadsStore()
|
||||
const themeStore = useThemeStore()
|
||||
const { connect } = useSSE()
|
||||
|
||||
|
|
@ -14,6 +16,7 @@ onMounted(async () => {
|
|||
themeStore.init()
|
||||
await configStore.loadConfig()
|
||||
await themeStore.loadCustomThemes()
|
||||
await downloadsStore.fetchJobs()
|
||||
connect()
|
||||
})
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ export interface ProgressEvent {
|
|||
downloaded_bytes: number | null
|
||||
total_bytes: number | null
|
||||
filename: string | null
|
||||
error_message?: string | null
|
||||
}
|
||||
|
||||
export interface FormatInfo {
|
||||
|
|
@ -95,6 +96,7 @@ export interface UrlInfo {
|
|||
entries: UrlInfoEntry[]
|
||||
duration?: number | null
|
||||
is_audio_only: boolean
|
||||
unavailable_count?: number
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -229,6 +229,9 @@ function formatTooltip(fmt: string): string {
|
|||
<div v-if="urlInfo.entries.length > 10" class="preview-more">
|
||||
…and {{ urlInfo.entries.length - 10 }} more
|
||||
</div>
|
||||
<div v-if="urlInfo.unavailable_count" class="preview-warning">
|
||||
⚠ {{ urlInfo.unavailable_count }} private/unavailable item{{ urlInfo.unavailable_count > 1 ? 's' : '' }} skipped
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -588,6 +591,15 @@ button:disabled {
|
|||
font-style: italic;
|
||||
}
|
||||
|
||||
.preview-warning {
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
margin-top: var(--space-xs);
|
||||
color: var(--color-warning);
|
||||
font-size: var(--font-size-sm);
|
||||
background: color-mix(in srgb, var(--color-warning) 10%, transparent);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.toggle-pill.disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@ export const useDownloadsStore = defineStore('downloads', () => {
|
|||
if (event.speed !== null) existing.speed = event.speed
|
||||
if (event.eta !== null) existing.eta = event.eta
|
||||
if (event.filename !== null) existing.filename = event.filename
|
||||
if (event.error_message) existing.error_message = event.error_message
|
||||
// Trigger reactivity by re-setting the map entry
|
||||
jobs.value.set(event.job_id, { ...existing })
|
||||
} else {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue