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.
This commit is contained in:
CyCl0ne
2026-03-09 08:59:01 +01:00
parent 3b0eaad67e
commit 56826d33fd
8 changed files with 111 additions and 8 deletions

View File

@@ -3,6 +3,13 @@ import copy
AUDIO_FORMATS = ("m4a", "mp3", "opus", "wav", "flac") AUDIO_FORMATS = ("m4a", "mp3", "opus", "wav", "flac")
CAPTION_MODES = ("auto_only", "manual_only", "prefer_manual", "prefer_auto") 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: def _normalize_caption_mode(mode: str) -> str:
mode = (mode or "").strip() mode = (mode or "").strip()
@@ -14,13 +21,14 @@ def _normalize_subtitle_language(language: str) -> str:
return language or "en" 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 Returns format for download
Args: Args:
format (str): format selected format (str): format selected
quality (str): quality selected quality (str): quality selected
video_codec (str): video codec filter (auto, h264, h265, av1, vp9)
Raises: Raises:
Exception: unknown quality, unknown format 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 ("", "") vfmt, afmt = ("[ext=mp4]", "[ext=m4a]") if format == "mp4" else ("", "")
vres = f"[height<={quality}]" if quality not in ("best", "best_ios", "worst") else "" vres = f"[height<={quality}]" if quality not in ("best", "best_ios", "worst") else ""
vcombo = vres + vfmt vcombo = vres + vfmt
codec_filter = CODEC_FILTER_MAP.get(video_codec, "")
if quality == "best_ios": if quality == "best_ios":
# iOS has strict requirements for video files, requiring h264 or h265 # 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 # convert if needed), and falls back to getting the best available MP4
# file. # 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}" 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}" return f"bestvideo{vcombo}+bestaudio{afmt}/best{vcombo}"
raise Exception(f"Unkown format {format}") raise Exception(f"Unkown format {format}")
@@ -73,6 +86,7 @@ def get_opts(
subtitle_format: str = "srt", subtitle_format: str = "srt",
subtitle_language: str = "en", subtitle_language: str = "en",
subtitle_mode: str = "prefer_manual", subtitle_mode: str = "prefer_manual",
video_codec: str = "auto",
) -> dict: ) -> dict:
""" """
Returns extra download options Returns extra download options

View File

@@ -288,6 +288,7 @@ async def add(request):
subtitle_format = post.get('subtitle_format') subtitle_format = post.get('subtitle_format')
subtitle_language = post.get('subtitle_language') subtitle_language = post.get('subtitle_language')
subtitle_mode = post.get('subtitle_mode') subtitle_mode = post.get('subtitle_mode')
video_codec = post.get('video_codec')
if custom_name_prefix is None: if custom_name_prefix is None:
custom_name_prefix = '' custom_name_prefix = ''
@@ -319,6 +320,12 @@ async def add(request):
if subtitle_mode not in VALID_SUBTITLE_MODES: if subtitle_mode not in VALID_SUBTITLE_MODES:
raise web.HTTPBadRequest(reason=f'subtitle_mode must be one of {sorted(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) playlist_item_limit = int(playlist_item_limit)
status = await dqueue.add( status = await dqueue.add(
@@ -334,6 +341,7 @@ async def add(request):
subtitle_format, subtitle_format,
subtitle_language, subtitle_language,
subtitle_mode, subtitle_mode,
video_codec=video_codec,
) )
return web.Response(text=serializer.encode(status)) return web.Response(text=serializer.encode(status))

View File

@@ -162,6 +162,7 @@ class DownloadInfo:
subtitle_format="srt", subtitle_format="srt",
subtitle_language="en", subtitle_language="en",
subtitle_mode="prefer_manual", subtitle_mode="prefer_manual",
video_codec="auto",
): ):
self.id = id if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{id}' 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}' 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_language = subtitle_language
self.subtitle_mode = subtitle_mode self.subtitle_mode = subtitle_mode
self.subtitle_files = [] self.subtitle_files = []
self.video_codec = video_codec
class Download: class Download:
manager = None manager = None
@@ -194,7 +196,7 @@ class Download:
self.output_template = output_template self.output_template = output_template
self.output_template_chapter = output_template_chapter self.output_template_chapter = output_template_chapter
self.info = info 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( self.ytdl_opts = get_opts(
format, format,
quality, quality,
@@ -202,6 +204,7 @@ class Download:
subtitle_format=getattr(info, 'subtitle_format', 'srt'), subtitle_format=getattr(info, 'subtitle_format', 'srt'),
subtitle_language=getattr(info, 'subtitle_language', 'en'), subtitle_language=getattr(info, 'subtitle_language', 'en'),
subtitle_mode=getattr(info, 'subtitle_mode', 'prefer_manual'), subtitle_mode=getattr(info, 'subtitle_mode', 'prefer_manual'),
video_codec=getattr(info, 'video_codec', 'auto'),
) )
if "impersonate" in self.ytdl_opts: if "impersonate" in self.ytdl_opts:
self.ytdl_opts["impersonate"] = yt_dlp.networking.impersonate.ImpersonateTarget.from_str(self.ytdl_opts["impersonate"]) self.ytdl_opts["impersonate"] = yt_dlp.networking.impersonate.ImpersonateTarget.from_str(self.ytdl_opts["impersonate"])
@@ -687,6 +690,7 @@ class DownloadQueue:
subtitle_mode, subtitle_mode,
already, already,
_add_gen=None, _add_gen=None,
video_codec="auto",
): ):
if not entry: if not entry:
return {'status': 'error', 'msg': "Invalid/empty data was given."} return {'status': 'error', 'msg': "Invalid/empty data was given."}
@@ -718,6 +722,7 @@ class DownloadQueue:
subtitle_mode, subtitle_mode,
already, already,
_add_gen, _add_gen,
video_codec,
) )
elif etype == 'playlist' or etype == 'channel': elif etype == 'playlist' or etype == 'channel':
log.debug(f'Processing as a {etype}') log.debug(f'Processing as a {etype}')
@@ -757,6 +762,7 @@ class DownloadQueue:
subtitle_mode, subtitle_mode,
already, already,
_add_gen, _add_gen,
video_codec,
) )
) )
if any(res['status'] == 'error' for res in results): if any(res['status'] == 'error' for res in results):
@@ -785,6 +791,7 @@ class DownloadQueue:
subtitle_format, subtitle_format,
subtitle_language, subtitle_language,
subtitle_mode, subtitle_mode,
video_codec,
) )
await self.__add_download(dl, auto_start) await self.__add_download(dl, auto_start)
return {'status': 'ok'} return {'status': 'ok'}
@@ -806,11 +813,13 @@ class DownloadQueue:
subtitle_mode="prefer_manual", subtitle_mode="prefer_manual",
already=None, already=None,
_add_gen=None, _add_gen=None,
video_codec="auto",
): ):
log.info( log.info(
f'adding {url}: {quality=} {format=} {already=} {folder=} {custom_name_prefix=} ' f'adding {url}: {quality=} {format=} {already=} {folder=} {custom_name_prefix=} '
f'{playlist_item_limit=} {auto_start=} {split_by_chapters=} {chapter_template=} ' 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: if already is None:
_add_gen = self._add_generation _add_gen = self._add_generation
@@ -840,6 +849,7 @@ class DownloadQueue:
subtitle_mode, subtitle_mode,
already, already,
_add_gen, _add_gen,
video_codec,
) )
async def start_pending(self, ids): async def start_pending(self, ids):

View File

@@ -77,7 +77,8 @@
"buildTarget": "metube:build:production" "buildTarget": "metube:build:production"
}, },
"development": { "development": {
"buildTarget": "metube:build:development" "buildTarget": "metube:build:development",
"proxyConfig": "proxy.conf.json"
} }
}, },
"defaultConfiguration": "development" "defaultConfiguration": "development"

View File

@@ -279,6 +279,23 @@
</div> </div>
</div> </div>
} }
@if (isVideoType()) {
<div class="col-md-4">
<div class="input-group">
<span class="input-group-text">Video Codec</span>
<select class="form-select"
name="videoCodec"
[(ngModel)]="videoCodec"
(change)="videoCodecChanged()"
[disabled]="addInProgress || downloads.loading"
ngbTooltip="Prefer a specific video codec. Falls back to best available if not found.">
@for (vc of videoCodecs; track vc.id) {
<option [ngValue]="vc.id">{{ vc.text }}</option>
}
</select>
</div>
</div>
}
<div class="col-12"> <div class="col-12">
<div class="row g-2 align-items-center"> <div class="row g-2 align-items-center">
<div class="col-auto"> <div class="col-auto">
@@ -489,6 +506,8 @@
<app-master-checkbox #doneMasterCheckboxRef [id]="'done'" [list]="downloads.done" (changed)="doneSelectionChanged($event)" /> <app-master-checkbox #doneMasterCheckboxRef [id]="'done'" [list]="downloads.done" (changed)="doneSelectionChanged($event)" />
</th> </th>
<th scope="col">Video</th> <th scope="col">Video</th>
<th scope="col">Quality</th>
<th scope="col">Codec</th>
<th scope="col">File Size</th> <th scope="col">File Size</th>
<th scope="col">Downloaded</th> <th scope="col">Downloaded</th>
<th scope="col" style="width: 8rem;"></th> <th scope="col" style="width: 8rem;"></th>
@@ -552,6 +571,12 @@
</div> </div>
} }
</td> </td>
<td class="text-nowrap">
{{ formatQualityLabel(entry[1]) }}
</td>
<td class="text-nowrap">
{{ formatCodecLabel(entry[1]) }}
</td>
<td> <td>
@if (entry[1].size) { @if (entry[1].size) {
<span>{{ entry[1].size | fileSize }}</span> <span>{{ entry[1].size | fileSize }}</span>
@@ -586,6 +611,8 @@
getChapterFileName(chapterFile.filename) }}</a> getChapterFileName(chapterFile.filename) }}</a>
</div> </div>
</td> </td>
<td></td>
<td></td>
<td> <td>
@if (chapterFile.size) { @if (chapterFile.size) {
<span>{{ chapterFile.size | fileSize }}</span> <span>{{ chapterFile.size | fileSize }}</span>

View File

@@ -53,6 +53,7 @@ export class App implements AfterViewInit, OnInit {
subtitleFormat: string; subtitleFormat: string;
subtitleLanguage: string; subtitleLanguage: string;
subtitleMode: string; subtitleMode: string;
videoCodec: string;
addInProgress = false; addInProgress = false;
cancelRequested = false; cancelRequested = false;
hasCookies = false; hasCookies = false;
@@ -169,6 +170,13 @@ export class App implements AfterViewInit, OnInit {
{ id: 'manual_only', text: 'Manual Only' }, { id: 'manual_only', text: 'Manual Only' },
{ id: 'auto_only', text: 'Auto 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() { constructor() {
this.format = this.cookieService.get('metube_format') || 'any'; 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.subtitleFormat = this.cookieService.get('metube_subtitle_format') || 'srt';
this.subtitleLanguage = this.cookieService.get('metube_subtitle_language') || 'en'; this.subtitleLanguage = this.cookieService.get('metube_subtitle_language') || 'en';
this.subtitleMode = this.cookieService.get('metube_subtitle_mode') || 'prefer_manual'; 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 allowedSubtitleFormats = new Set(this.subtitleFormats.map(fmt => fmt.id));
const allowedSubtitleModes = new Set(this.subtitleModes.map(mode => mode.id)); const allowedSubtitleModes = new Set(this.subtitleModes.map(mode => mode.id));
if (!allowedSubtitleFormats.has(this.subtitleFormat)) { 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 }); 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) { queueSelectionChanged(checked: number) {
this.queueDelSelected().nativeElement.disabled = checked == 0; this.queueDelSelected().nativeElement.disabled = checked == 0;
this.queueDownloadSelected().nativeElement.disabled = checked == 0; this.queueDownloadSelected().nativeElement.disabled = checked == 0;
@@ -412,6 +447,7 @@ export class App implements AfterViewInit, OnInit {
subtitleFormat?: string, subtitleFormat?: string,
subtitleLanguage?: string, subtitleLanguage?: string,
subtitleMode?: string, subtitleMode?: string,
videoCodec?: string,
) { ) {
url = url ?? this.addUrl url = url ?? this.addUrl
quality = quality ?? this.quality quality = quality ?? this.quality
@@ -425,6 +461,7 @@ export class App implements AfterViewInit, OnInit {
subtitleFormat = subtitleFormat ?? this.subtitleFormat subtitleFormat = subtitleFormat ?? this.subtitleFormat
subtitleLanguage = subtitleLanguage ?? this.subtitleLanguage subtitleLanguage = subtitleLanguage ?? this.subtitleLanguage
subtitleMode = subtitleMode ?? this.subtitleMode subtitleMode = subtitleMode ?? this.subtitleMode
videoCodec = videoCodec ?? this.videoCodec
// Validate chapter template if chapter splitting is enabled // Validate chapter template if chapter splitting is enabled
if (splitByChapters && !chapterTemplate.includes('%(section_number)')) { if (splitByChapters && !chapterTemplate.includes('%(section_number)')) {
@@ -432,10 +469,10 @@ export class App implements AfterViewInit, OnInit {
return; 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.addInProgress = true;
this.cancelRequested = false; 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) { if (status.status === 'error' && !this.cancelRequested) {
alert(`Error adding URL: ${status.msg}`); alert(`Error adding URL: ${status.msg}`);
} else if (status.status !== 'error') { } else if (status.status !== 'error') {
@@ -473,6 +510,7 @@ export class App implements AfterViewInit, OnInit {
download.subtitle_format, download.subtitle_format,
download.subtitle_language, download.subtitle_language,
download.subtitle_mode, download.subtitle_mode,
download.video_codec,
); );
this.downloads.delById('done', [key]).subscribe(); 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 // 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.downloads.add(url, this.quality, this.format, this.folder, this.customNamePrefix,
this.playlistItemLimit, this.autoStart, this.splitByChapters, this.chapterTemplate, this.playlistItemLimit, this.autoStart, this.splitByChapters, this.chapterTemplate,
this.subtitleFormat, this.subtitleLanguage, this.subtitleMode) this.subtitleFormat, this.subtitleLanguage, this.subtitleMode, this.videoCodec)
.subscribe({ .subscribe({
next: (status: Status) => { next: (status: Status) => {
if (status.status === 'error') { if (status.status === 'error') {

View File

@@ -13,6 +13,7 @@ export interface Download {
subtitle_format?: string; subtitle_format?: string;
subtitle_language?: string; subtitle_language?: string;
subtitle_mode?: string; subtitle_mode?: string;
video_codec?: string;
status: string; status: string;
msg: string; msg: string;
percent: number; percent: number;

View File

@@ -120,6 +120,7 @@ export class DownloadsService {
subtitleFormat: string, subtitleFormat: string,
subtitleLanguage: string, subtitleLanguage: string,
subtitleMode: string, subtitleMode: string,
videoCodec: string,
) { ) {
return this.http.post<Status>('add', { return this.http.post<Status>('add', {
url: url, url: url,
@@ -133,7 +134,8 @@ export class DownloadsService {
chapter_template: chapterTemplate, chapter_template: chapterTemplate,
subtitle_format: subtitleFormat, subtitle_format: subtitleFormat,
subtitle_language: subtitleLanguage, subtitle_language: subtitleLanguage,
subtitle_mode: subtitleMode subtitle_mode: subtitleMode,
video_codec: videoCodec,
}).pipe( }).pipe(
catchError(this.handleHTTPError) catchError(this.handleHTTPError)
); );
@@ -183,6 +185,7 @@ export class DownloadsService {
const defaultSubtitleFormat = 'srt'; const defaultSubtitleFormat = 'srt';
const defaultSubtitleLanguage = 'en'; const defaultSubtitleLanguage = 'en';
const defaultSubtitleMode = 'prefer_manual'; const defaultSubtitleMode = 'prefer_manual';
const defaultVideoCodec = 'auto';
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.add( this.add(
@@ -198,6 +201,7 @@ export class DownloadsService {
defaultSubtitleFormat, defaultSubtitleFormat,
defaultSubtitleLanguage, defaultSubtitleLanguage,
defaultSubtitleMode, defaultSubtitleMode,
defaultVideoCodec,
) )
.subscribe({ .subscribe({
next: (response) => resolve(response), next: (response) => resolve(response),