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:
xpltd 2026-03-19 02:53:45 -05:00
parent 0d9e6b18ac
commit 3931e71af5
7 changed files with 101 additions and 25 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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
}
/**

View file

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

View file

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