From 56826d33fd1acb2877d7e8235e56ff8af2ab3fd4 Mon Sep 17 00:00:00 2001 From: CyCl0ne Date: Mon, 9 Mar 2026 08:59:01 +0100 Subject: [PATCH] Add video codec selector and codec/quality columns in done list Allow users to prefer a specific video codec (H.264, H.265, AV1, VP9) when adding downloads. The selector filters available formats via yt-dlp format strings, falling back to best available if the preferred codec is not found. The completed downloads table now shows Quality and Codec columns. --- app/dl_formats.py | 16 ++++++++- app/main.py | 8 +++++ app/ytdl.py | 14 ++++++-- ui/angular.json | 3 +- ui/src/app/app.html | 27 +++++++++++++++ ui/src/app/app.ts | 44 ++++++++++++++++++++++-- ui/src/app/interfaces/download.ts | 1 + ui/src/app/services/downloads.service.ts | 6 +++- 8 files changed, 111 insertions(+), 8 deletions(-) diff --git a/app/dl_formats.py b/app/dl_formats.py index e94ec9a..283f53c 100644 --- a/app/dl_formats.py +++ b/app/dl_formats.py @@ -3,6 +3,13 @@ import copy AUDIO_FORMATS = ("m4a", "mp3", "opus", "wav", "flac") CAPTION_MODES = ("auto_only", "manual_only", "prefer_manual", "prefer_auto") +CODEC_FILTER_MAP = { + 'h264': "[vcodec~='^(h264|avc)']", + 'h265': "[vcodec~='^(h265|hevc)']", + 'av1': "[vcodec~='^av0?1']", + 'vp9': "[vcodec~='^vp0?9']", +} + def _normalize_caption_mode(mode: str) -> str: mode = (mode or "").strip() @@ -14,13 +21,14 @@ def _normalize_subtitle_language(language: str) -> str: return language or "en" -def get_format(format: str, quality: str) -> str: +def get_format(format: str, quality: str, video_codec: str = "auto") -> str: """ Returns format for download Args: format (str): format selected quality (str): quality selected + video_codec (str): video codec filter (auto, h264, h265, av1, vp9) Raises: Exception: unknown quality, unknown format @@ -52,6 +60,7 @@ def get_format(format: str, quality: str) -> str: vfmt, afmt = ("[ext=mp4]", "[ext=m4a]") if format == "mp4" else ("", "") vres = f"[height<={quality}]" if quality not in ("best", "best_ios", "worst") else "" vcombo = vres + vfmt + codec_filter = CODEC_FILTER_MAP.get(video_codec, "") if quality == "best_ios": # iOS has strict requirements for video files, requiring h264 or h265 @@ -61,6 +70,10 @@ def get_format(format: str, quality: str) -> str: # convert if needed), and falls back to getting the best available MP4 # file. return f"bestvideo[vcodec~='^((he|a)vc|h26[45])']{vres}+bestaudio[acodec=aac]/bestvideo[vcodec~='^((he|a)vc|h26[45])']{vres}+bestaudio{afmt}/bestvideo{vcombo}+bestaudio{afmt}/best{vcombo}" + + if codec_filter: + # Try codec-filtered first, fall back to unfiltered + return f"bestvideo{codec_filter}{vcombo}+bestaudio{afmt}/bestvideo{vcombo}+bestaudio{afmt}/best{vcombo}" return f"bestvideo{vcombo}+bestaudio{afmt}/best{vcombo}" raise Exception(f"Unkown format {format}") @@ -73,6 +86,7 @@ def get_opts( subtitle_format: str = "srt", subtitle_language: str = "en", subtitle_mode: str = "prefer_manual", + video_codec: str = "auto", ) -> dict: """ Returns extra download options diff --git a/app/main.py b/app/main.py index 74ac76a..b2d9f14 100644 --- a/app/main.py +++ b/app/main.py @@ -288,6 +288,7 @@ async def add(request): subtitle_format = post.get('subtitle_format') subtitle_language = post.get('subtitle_language') subtitle_mode = post.get('subtitle_mode') + video_codec = post.get('video_codec') if custom_name_prefix is None: custom_name_prefix = '' @@ -319,6 +320,12 @@ async def add(request): if subtitle_mode not in VALID_SUBTITLE_MODES: raise web.HTTPBadRequest(reason=f'subtitle_mode must be one of {sorted(VALID_SUBTITLE_MODES)}') + if video_codec is None: + video_codec = 'auto' + video_codec = str(video_codec).strip().lower() + if video_codec not in ('auto', 'h264', 'h265', 'av1', 'vp9'): + raise web.HTTPBadRequest(reason="video_codec must be one of ['auto', 'h264', 'h265', 'av1', 'vp9']") + playlist_item_limit = int(playlist_item_limit) status = await dqueue.add( @@ -334,6 +341,7 @@ async def add(request): subtitle_format, subtitle_language, subtitle_mode, + video_codec=video_codec, ) return web.Response(text=serializer.encode(status)) diff --git a/app/ytdl.py b/app/ytdl.py index f73eb36..370c458 100644 --- a/app/ytdl.py +++ b/app/ytdl.py @@ -162,6 +162,7 @@ class DownloadInfo: subtitle_format="srt", subtitle_language="en", subtitle_mode="prefer_manual", + video_codec="auto", ): self.id = id if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{id}' self.title = title if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{title}' @@ -184,6 +185,7 @@ class DownloadInfo: self.subtitle_language = subtitle_language self.subtitle_mode = subtitle_mode self.subtitle_files = [] + self.video_codec = video_codec class Download: manager = None @@ -194,7 +196,7 @@ class Download: self.output_template = output_template self.output_template_chapter = output_template_chapter self.info = info - self.format = get_format(format, quality) + self.format = get_format(format, quality, video_codec=getattr(info, 'video_codec', 'auto')) self.ytdl_opts = get_opts( format, quality, @@ -202,6 +204,7 @@ class Download: subtitle_format=getattr(info, 'subtitle_format', 'srt'), subtitle_language=getattr(info, 'subtitle_language', 'en'), subtitle_mode=getattr(info, 'subtitle_mode', 'prefer_manual'), + video_codec=getattr(info, 'video_codec', 'auto'), ) if "impersonate" in self.ytdl_opts: self.ytdl_opts["impersonate"] = yt_dlp.networking.impersonate.ImpersonateTarget.from_str(self.ytdl_opts["impersonate"]) @@ -687,6 +690,7 @@ class DownloadQueue: subtitle_mode, already, _add_gen=None, + video_codec="auto", ): if not entry: return {'status': 'error', 'msg': "Invalid/empty data was given."} @@ -718,6 +722,7 @@ class DownloadQueue: subtitle_mode, already, _add_gen, + video_codec, ) elif etype == 'playlist' or etype == 'channel': log.debug(f'Processing as a {etype}') @@ -757,6 +762,7 @@ class DownloadQueue: subtitle_mode, already, _add_gen, + video_codec, ) ) if any(res['status'] == 'error' for res in results): @@ -785,6 +791,7 @@ class DownloadQueue: subtitle_format, subtitle_language, subtitle_mode, + video_codec, ) await self.__add_download(dl, auto_start) return {'status': 'ok'} @@ -806,11 +813,13 @@ class DownloadQueue: subtitle_mode="prefer_manual", already=None, _add_gen=None, + video_codec="auto", ): log.info( f'adding {url}: {quality=} {format=} {already=} {folder=} {custom_name_prefix=} ' f'{playlist_item_limit=} {auto_start=} {split_by_chapters=} {chapter_template=} ' - f'{subtitle_format=} {subtitle_language=} {subtitle_mode=}' + f'{subtitle_format=} {subtitle_language=} {subtitle_mode=} ' + f'{video_codec=}' ) if already is None: _add_gen = self._add_generation @@ -840,6 +849,7 @@ class DownloadQueue: subtitle_mode, already, _add_gen, + video_codec, ) async def start_pending(self, ids): diff --git a/ui/angular.json b/ui/angular.json index 229711e..6f0fddb 100644 --- a/ui/angular.json +++ b/ui/angular.json @@ -77,7 +77,8 @@ "buildTarget": "metube:build:production" }, "development": { - "buildTarget": "metube:build:development" + "buildTarget": "metube:build:development", + "proxyConfig": "proxy.conf.json" } }, "defaultConfiguration": "development" diff --git a/ui/src/app/app.html b/ui/src/app/app.html index 202ee80..56a0b44 100644 --- a/ui/src/app/app.html +++ b/ui/src/app/app.html @@ -279,6 +279,23 @@ } + @if (isVideoType()) { +
+
+ Video Codec + +
+
+ }
@@ -489,6 +506,8 @@ Video + Quality + Codec File Size Downloaded @@ -552,6 +571,12 @@
} + + {{ formatQualityLabel(entry[1]) }} + + + {{ formatCodecLabel(entry[1]) }} + @if (entry[1].size) { {{ entry[1].size | fileSize }} @@ -586,6 +611,8 @@ getChapterFileName(chapterFile.filename) }}
+ + @if (chapterFile.size) { {{ chapterFile.size | fileSize }} diff --git a/ui/src/app/app.ts b/ui/src/app/app.ts index e34b635..0228f31 100644 --- a/ui/src/app/app.ts +++ b/ui/src/app/app.ts @@ -53,6 +53,7 @@ export class App implements AfterViewInit, OnInit { subtitleFormat: string; subtitleLanguage: string; subtitleMode: string; + videoCodec: string; addInProgress = false; cancelRequested = false; hasCookies = false; @@ -169,6 +170,13 @@ export class App implements AfterViewInit, OnInit { { id: 'manual_only', text: 'Manual Only' }, { id: 'auto_only', text: 'Auto Only' }, ]; + videoCodecs = [ + { id: 'auto', text: 'Auto' }, + { id: 'h264', text: 'H.264' }, + { id: 'h265', text: 'H.265 (HEVC)' }, + { id: 'av1', text: 'AV1' }, + { id: 'vp9', text: 'VP9' }, + ]; constructor() { this.format = this.cookieService.get('metube_format') || 'any'; @@ -182,6 +190,11 @@ export class App implements AfterViewInit, OnInit { this.subtitleFormat = this.cookieService.get('metube_subtitle_format') || 'srt'; this.subtitleLanguage = this.cookieService.get('metube_subtitle_language') || 'en'; this.subtitleMode = this.cookieService.get('metube_subtitle_mode') || 'prefer_manual'; + this.videoCodec = this.cookieService.get('metube_video_codec') || 'auto'; + const allowedVideoCodecs = new Set(this.videoCodecs.map(c => c.id)); + if (!allowedVideoCodecs.has(this.videoCodec)) { + this.videoCodec = 'auto'; + } const allowedSubtitleFormats = new Set(this.subtitleFormats.map(fmt => fmt.id)); const allowedSubtitleModes = new Set(this.subtitleModes.map(mode => mode.id)); if (!allowedSubtitleFormats.has(this.subtitleFormat)) { @@ -379,6 +392,28 @@ export class App implements AfterViewInit, OnInit { this.cookieService.set('metube_subtitle_mode', this.subtitleMode, { expires: 3650 }); } + videoCodecChanged() { + this.cookieService.set('metube_video_codec', this.videoCodec, { expires: 3650 }); + } + + isVideoType() { + return (this.format === 'any' || this.format === 'mp4') && this.quality !== 'audio'; + } + + formatQualityLabel(download: Download): string { + const q = download.quality; + if (!q) return ''; + if (/^\d+$/.test(q)) return `${q}p`; + if (q === 'best_ios') return 'Best (iOS)'; + return q.charAt(0).toUpperCase() + q.slice(1); + } + + formatCodecLabel(download: Download): string { + const codec = download.video_codec; + if (!codec || codec === 'auto') return 'Auto'; + return this.videoCodecs.find(c => c.id === codec)?.text ?? codec; + } + queueSelectionChanged(checked: number) { this.queueDelSelected().nativeElement.disabled = checked == 0; this.queueDownloadSelected().nativeElement.disabled = checked == 0; @@ -412,6 +447,7 @@ export class App implements AfterViewInit, OnInit { subtitleFormat?: string, subtitleLanguage?: string, subtitleMode?: string, + videoCodec?: string, ) { url = url ?? this.addUrl quality = quality ?? this.quality @@ -425,6 +461,7 @@ export class App implements AfterViewInit, OnInit { subtitleFormat = subtitleFormat ?? this.subtitleFormat subtitleLanguage = subtitleLanguage ?? this.subtitleLanguage subtitleMode = subtitleMode ?? this.subtitleMode + videoCodec = videoCodec ?? this.videoCodec // Validate chapter template if chapter splitting is enabled if (splitByChapters && !chapterTemplate.includes('%(section_number)')) { @@ -432,10 +469,10 @@ export class App implements AfterViewInit, OnInit { return; } - console.debug('Downloading: url=' + url + ' quality=' + quality + ' format=' + format + ' folder=' + folder + ' customNamePrefix=' + customNamePrefix + ' playlistItemLimit=' + playlistItemLimit + ' autoStart=' + autoStart + ' splitByChapters=' + splitByChapters + ' chapterTemplate=' + chapterTemplate + ' subtitleFormat=' + subtitleFormat + ' subtitleLanguage=' + subtitleLanguage + ' subtitleMode=' + subtitleMode); + console.debug('Downloading: url=' + url + ' quality=' + quality + ' format=' + format + ' folder=' + folder + ' customNamePrefix=' + customNamePrefix + ' playlistItemLimit=' + playlistItemLimit + ' autoStart=' + autoStart + ' splitByChapters=' + splitByChapters + ' chapterTemplate=' + chapterTemplate + ' subtitleFormat=' + subtitleFormat + ' subtitleLanguage=' + subtitleLanguage + ' subtitleMode=' + subtitleMode + ' videoCodec=' + videoCodec); this.addInProgress = true; this.cancelRequested = false; - this.downloads.add(url, quality, format, folder, customNamePrefix, playlistItemLimit, autoStart, splitByChapters, chapterTemplate, subtitleFormat, subtitleLanguage, subtitleMode).subscribe((status: Status) => { + this.downloads.add(url, quality, format, folder, customNamePrefix, playlistItemLimit, autoStart, splitByChapters, chapterTemplate, subtitleFormat, subtitleLanguage, subtitleMode, videoCodec).subscribe((status: Status) => { if (status.status === 'error' && !this.cancelRequested) { alert(`Error adding URL: ${status.msg}`); } else if (status.status !== 'error') { @@ -473,6 +510,7 @@ export class App implements AfterViewInit, OnInit { download.subtitle_format, download.subtitle_language, download.subtitle_mode, + download.video_codec, ); this.downloads.delById('done', [key]).subscribe(); } @@ -620,7 +658,7 @@ export class App implements AfterViewInit, OnInit { // Now pass the selected quality, format, folder, etc. to the add() method this.downloads.add(url, this.quality, this.format, this.folder, this.customNamePrefix, this.playlistItemLimit, this.autoStart, this.splitByChapters, this.chapterTemplate, - this.subtitleFormat, this.subtitleLanguage, this.subtitleMode) + this.subtitleFormat, this.subtitleLanguage, this.subtitleMode, this.videoCodec) .subscribe({ next: (status: Status) => { if (status.status === 'error') { diff --git a/ui/src/app/interfaces/download.ts b/ui/src/app/interfaces/download.ts index 7e709c2..6d69785 100644 --- a/ui/src/app/interfaces/download.ts +++ b/ui/src/app/interfaces/download.ts @@ -13,6 +13,7 @@ export interface Download { subtitle_format?: string; subtitle_language?: string; subtitle_mode?: string; + video_codec?: string; status: string; msg: string; percent: number; diff --git a/ui/src/app/services/downloads.service.ts b/ui/src/app/services/downloads.service.ts index 37f4dc7..94d629d 100644 --- a/ui/src/app/services/downloads.service.ts +++ b/ui/src/app/services/downloads.service.ts @@ -120,6 +120,7 @@ export class DownloadsService { subtitleFormat: string, subtitleLanguage: string, subtitleMode: string, + videoCodec: string, ) { return this.http.post('add', { url: url, @@ -133,7 +134,8 @@ export class DownloadsService { chapter_template: chapterTemplate, subtitle_format: subtitleFormat, subtitle_language: subtitleLanguage, - subtitle_mode: subtitleMode + subtitle_mode: subtitleMode, + video_codec: videoCodec, }).pipe( catchError(this.handleHTTPError) ); @@ -183,6 +185,7 @@ export class DownloadsService { const defaultSubtitleFormat = 'srt'; const defaultSubtitleLanguage = 'en'; const defaultSubtitleMode = 'prefer_manual'; + const defaultVideoCodec = 'auto'; return new Promise((resolve, reject) => { this.add( @@ -198,6 +201,7 @@ export class DownloadsService { defaultSubtitleFormat, defaultSubtitleLanguage, defaultSubtitleMode, + defaultVideoCodec, ) .subscribe({ next: (response) => resolve(response),