From 6e27f8e424ed77361e21e3aa6222d018460bca44 Mon Sep 17 00:00:00 2001 From: xpltd Date: Thu, 19 Mar 2026 01:03:21 -0500 Subject: [PATCH] M002/S04: output format selection, media icons, gear repositioned - Move gear icon to left of Video/Audio toggle - Add output format selector in options panel: Audio: Auto, MP3, WAV, M4A, FLAC, OPUS Video: Auto, MP4, WEBM - Backend: postprocess audio to selected codec via FFmpegExtractAudio - Backend: postprocessor_hooks capture final filename after conversion (fixes .webm showing when file was converted to .mp3) - Add media type icon (video camera / music note) in download table next to filename, inferred from quality field and file extension - Pass media_type and output_format through JobCreate model --- backend/app/models/job.py | 2 + backend/app/services/download.py | 42 ++++++ frontend/src/api/types.ts | 2 + frontend/src/components/DownloadTable.vue | 38 ++++- frontend/src/components/UrlInput.vue | 168 ++++++++++++++++------ 5 files changed, 209 insertions(+), 43 deletions(-) diff --git a/backend/app/models/job.py b/backend/app/models/job.py index 8cdc92a..06fd474 100644 --- a/backend/app/models/job.py +++ b/backend/app/models/job.py @@ -25,6 +25,8 @@ class JobCreate(BaseModel): format_id: str | None = None quality: str | None = None output_template: str | None = None + media_type: str | None = None # "video" | "audio" + output_format: str | None = None # e.g. "mp3", "wav", "mp4", "webm" class Job(BaseModel): diff --git a/backend/app/services/download.py b/backend/app/services/download.py index 5ed8ff3..00b5c54 100644 --- a/backend/app/services/download.py +++ b/backend/app/services/download.py @@ -119,6 +119,25 @@ class DownloadService: elif job_create.quality: opts["format"] = job_create.quality + # Output format post-processing (e.g. convert to mp3, mp4) + out_fmt = job_create.output_format + if out_fmt: + if out_fmt in ("mp3", "wav", "m4a", "flac", "opus"): + # Audio conversion via yt-dlp postprocessor + opts["postprocessors"] = [{ + "key": "FFmpegExtractAudio", + "preferredcodec": out_fmt, + "preferredquality": "0" if out_fmt in ("flac", "wav") else "192", + }] + elif out_fmt == "mp4": + # Prefer mp4-native streams; remux if needed + opts["merge_output_format"] = "mp4" + opts.setdefault("format", "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best") + opts["postprocessors"] = [{ + "key": "FFmpegVideoRemuxer", + "preferedformat": "mp4", + }] + self._loop.run_in_executor( self._executor, self._run_download, @@ -245,7 +264,25 @@ class DownloadService: except Exception: logger.exception("Job %s progress hook error (status=%s)", job_id, d.get("status")) + # Track final filename after postprocessing (e.g. audio conversion) + final_filename = [None] # mutable container for closure + + def postprocessor_hook(d: dict) -> None: + """Capture the final filename after postprocessing.""" + if d.get("status") == "finished": + info = d.get("info_dict", {}) + # After postprocessing, filepath reflects the converted file + filepath = info.get("filepath") or info.get("filename") + if filepath: + abs_path = Path(filepath).resolve() + out_dir = Path(self._config.downloads.output_dir).resolve() + try: + final_filename[0] = str(abs_path.relative_to(out_dir)) + except ValueError: + final_filename[0] = abs_path.name + opts["progress_hooks"] = [progress_hook] + opts["postprocessor_hooks"] = [postprocessor_hook] try: # Mark as downloading and notify SSE @@ -278,6 +315,11 @@ class DownloadService: ydl.download([url]) + # Use postprocessor's final filename if available (handles + # audio conversion changing .webm → .mp3 etc.) + if final_filename[0]: + relative_fn = final_filename[0] + # Persist filename to DB (progress hooks may not have fired # if the file already existed) if relative_fn: diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 6ad99c3..c97e0c1 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -40,6 +40,8 @@ export interface JobCreate { format_id?: string | null quality?: string | null output_template?: string | null + media_type?: string | null // "video" | "audio" + output_format?: string | null // e.g. "mp3", "wav", "mp4", "webm" } export interface ProgressEvent { diff --git a/frontend/src/components/DownloadTable.vue b/frontend/src/components/DownloadTable.vue index b2af378..3128f7b 100644 --- a/frontend/src/components/DownloadTable.vue +++ b/frontend/src/components/DownloadTable.vue @@ -102,6 +102,16 @@ function isCompleted(job: Job): boolean { return job.status === 'completed' } +/** Infer whether the job is audio or video from quality/filename. */ +function isAudioJob(job: Job): boolean { + if (job.quality === 'bestaudio') return true + if (job.filename) { + const ext = job.filename.split('.').pop()?.toLowerCase() || '' + if (['mp3', 'wav', 'flac', 'opus', 'm4a', 'aac', 'ogg', 'wma'].includes(ext)) return true + } + return false +} + // File download URL — filename is relative to the output directory // (normalized by the backend). May contain subdirectories for source templates. function downloadUrl(job: Job): string { @@ -171,7 +181,14 @@ async function clearJob(jobId: string): Promise { - {{ displayName(job) }} + + + + + + {{ displayName(job) }} + + {{ job.status }} @@ -316,6 +333,25 @@ async function clearJob(jobId: string): Promise { white-space: nowrap; } +.name-with-icon { + display: flex; + align-items: center; + gap: var(--space-sm); + overflow: hidden; +} + +.media-icon { + flex-shrink: 0; + color: var(--color-text-muted); + opacity: 0.7; +} + +.name-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .col-status { width: 100px; } .col-progress { width: 180px; min-width: 120px; } .col-speed { width: 100px; } diff --git a/frontend/src/components/UrlInput.vue b/frontend/src/components/UrlInput.vue index 980549f..f6e226a 100644 --- a/frontend/src/components/UrlInput.vue +++ b/frontend/src/components/UrlInput.vue @@ -1,5 +1,5 @@