From 681aad24cdd74cae2d54199393ee6b7a028ee22a Mon Sep 17 00:00:00 2001 From: Helmut Date: Fri, 29 May 2026 05:08:05 +0200 Subject: [PATCH] feat(ui): show actually-downloaded quality next to user's choice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the user picks 'Best' (or any non-exact preset), the completed-list quality cell shows only what they requested — but yt-dlp may have resolved it to anything from 360p to 4K60. Same when they pick a specific resolution that wasn't available and yt-dlp had to downgrade. This commit: - Backend (app/ytdl.py): captures yt-dlp's resolved width / height / fps / vcodec / abr in the MoveFiles postprocessor hook and persists them on DownloadInfo. __setstate__ backfills None for pre-patch persisted entries so old completed downloads keep loading. - Interface (ui/src/app/interfaces/download.ts): four new optional fields on Download. - UI (ui/src/app/app.ts:formatQualityLabel): when actual differs from requested, render 'Best · 1080p60' / '1080p · 720p' style. High frame rates (50+) get appended ('1080p60'); standard rates stay implicit. Audio shows 'Best · 320 kbps'. Identical actual === requested stays collapsed to avoid noise. Backward compatible: old persisted downloads render unchanged since their new fields stay None and formatActualQuality() returns null. --- app/ytdl.py | 36 ++++++++++++++++++++++++++++++- ui/src/app/app.ts | 27 ++++++++++++++++++++++- ui/src/app/interfaces/download.ts | 10 +++++++++ 3 files changed, 71 insertions(+), 2 deletions(-) diff --git a/app/ytdl.py b/app/ytdl.py index 31bca5e..a4bec20 100644 --- a/app/ytdl.py +++ b/app/ytdl.py @@ -231,10 +231,24 @@ class DownloadInfo: self.live_status = live_status self.live_release_timestamp = live_release_timestamp self.subtitle_files = [] + # Actual media properties captured from yt-dlp's info_dict when the + # download finishes. Unlike `quality`/`codec` (the user's request), + # these reflect what yt-dlp actually resolved to. Set lazily by the + # MoveFiles postprocessor hook, so they stay None for in-flight, + # failed, or non-media (captions/thumbnail) downloads. + self.width = None + self.height = None + self.fps = None + self.vcodec_actual = None + self.abr = None def __setstate__(self, state): """BACKWARD COMPATIBILITY: migrate old DownloadInfo from persistent queue files.""" self.__dict__.update(state) + # Backfill resolved-media fields for items persisted before this patch. + for field in ('width', 'height', 'fps', 'vcodec_actual', 'abr'): + if not hasattr(self, field): + setattr(self, field, None) if 'download_type' not in state: old_format = state.get('format', 'any') old_video_codec = state.get('video_codec', 'auto') @@ -454,7 +468,21 @@ class Download: filename = os.path.join(finaldir, os.path.basename(filepath)) else: filename = filepath - self.status_queue.put({'status': 'finished', 'filename': filename}) + status_update = {'status': 'finished', 'filename': filename} + # Capture actual media properties resolved by yt-dlp so the + # UI can show "Best · 1080p60" instead of just "Best". + # Skip vcodec='none' (audio-only formats) — that's not the + # video codec, it just means there isn't one. + info = d['info_dict'] + for src, dst in (('width', 'width'), ('height', 'height'), + ('fps', 'fps'), ('abr', 'abr')): + value = info.get(src) + if value is not None: + status_update[dst] = value + vcodec = info.get('vcodec') + if vcodec and vcodec != 'none': + status_update['vcodec_actual'] = vcodec + self.status_queue.put(status_update) # For captions-only downloads, yt-dlp may still report a media-like # filepath in MoveFiles. Capture subtitle outputs explicitly so the # UI can link to real caption files. @@ -585,6 +613,12 @@ class Download: self.info.size = os.path.getsize(fileName) if os.path.exists(fileName) else None if getattr(self.info, 'download_type', '') == 'thumbnail': self.info.filename = re.sub(r'\.webm$', '.jpg', self.info.filename) + # Capture actual media properties yt-dlp resolved (width / height / + # fps / actual vcodec / audio bitrate). Pushed by the MoveFiles + # postprocessor hook; never overwrite a present value with None. + for field in ('width', 'height', 'fps', 'vcodec_actual', 'abr'): + if field in status and status[field] is not None: + setattr(self.info, field, status[field]) # Handle chapter files log.debug(f"Update status for {self.info.title}: {status}") diff --git a/ui/src/app/app.ts b/ui/src/app/app.ts index 7245ab3..5d5df2c 100644 --- a/ui/src/app/app.ts +++ b/ui/src/app/app.ts @@ -862,11 +862,36 @@ export class App implements AfterViewInit, OnInit, OnDestroy { } const q = download.quality; if (!q) return ''; - if (/^\d+$/.test(q) && download.download_type === 'audio') return `${q} kbps`; + const requested = this.formatRequestedQuality(q, download.download_type); + const actual = this.formatActualQuality(download); + // Show the actual value next to the requested one only when it differs + // — most useful for "Best" / "Worst" (no exact label) or when yt-dlp + // had to downgrade (user requested 1080p, only 720p was available). + if (!actual || actual === requested) return requested; + return `${requested} · ${actual}`; + } + + private formatRequestedQuality(q: string, dl_type: string): string { + if (/^\d+$/.test(q) && dl_type === 'audio') return `${q} kbps`; if (/^\d+$/.test(q)) return `${q}p`; return q.charAt(0).toUpperCase() + q.slice(1); } + // Returns the actually-resolved quality from yt-dlp's info_dict + // (height+fps for video, abr for audio). Returns null for old persisted + // entries that pre-date the backend patch, or for non-media types. + private formatActualQuality(download: Download): string | null { + if (download.download_type === 'audio') { + return download.abr ? `${Math.round(download.abr)} kbps` : null; + } + if (!download.height) return null; + let label = `${download.height}p`; + // High frame rates (50+) are worth highlighting; standard 24/25/30 + // would clutter without telling the user anything new. + if (download.fps && download.fps >= 50) label += Math.round(download.fps); + return label; + } + downloadTypeLabel(download: Download): string { const type = download.download_type || 'video'; return type.charAt(0).toUpperCase() + type.slice(1); diff --git a/ui/src/app/interfaces/download.ts b/ui/src/app/interfaces/download.ts index 50de3cd..74fc970 100644 --- a/ui/src/app/interfaces/download.ts +++ b/ui/src/app/interfaces/download.ts @@ -32,4 +32,14 @@ export interface Download { error?: string; deleting?: boolean; chapter_files?: { filename: string, size: number }[]; + // Actual media properties resolved by yt-dlp at download time. Unlike + // `quality`/`codec` (the user's request), these reflect what came out. + // Optional because they're only populated for finished downloads of + // video/audio (not captions/thumbnails) and old persisted entries + // pre-dating the patch don't have them. + width?: number; + height?: number; + fps?: number; + vcodec_actual?: string; + abr?: number; }