From 927ed0807d4292ffec9de33aef5de265c951c763 Mon Sep 17 00:00:00 2001 From: Dakota Gravitt <19499852+dakotagrvtt@users.noreply.github.com> Date: Tue, 24 Feb 2026 16:27:09 -0600 Subject: [PATCH] feat(download): enforce strict audio_multistream gating and enrich stream metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Return `None` from `_get_audio_languages` when `audio_multistream` is not effectively enabled; `audio_languages` alone must not trigger multi-track downloads (strict mode) - Remove `audio_languages` from MKV container-forcing logic in - Extend `StreamItemSerializer` with optional `language`, `title`, `channels`, and `channel_layout` fields to surface per-track metadata - Improve `_extract_audio_metadata` to extract language (normalising "und" → None), track title, channel layout, and fall back to BPS/MKV tags when a stream-level bitrate is absent --- backend/download/src/yt_dlp_handler.py | 9 ++++++- backend/video/serializers.py | 4 +++ backend/video/src/index.py | 15 ++++++----- backend/video/src/media_streams.py | 31 ++++++++++++++++++++- frontend/src/pages/ChannelAbout.tsx | 37 ++++++++++++++++---------- frontend/src/pages/Home.tsx | 4 +++ frontend/src/pages/Video.tsx | 36 ++++++++++++++++++++----- 7 files changed, 107 insertions(+), 29 deletions(-) diff --git a/backend/download/src/yt_dlp_handler.py b/backend/download/src/yt_dlp_handler.py index 2b6360c8..7165e09c 100644 --- a/backend/download/src/yt_dlp_handler.py +++ b/backend/download/src/yt_dlp_handler.py @@ -376,7 +376,14 @@ class VideoDownloader(DownloaderBase): obs["audio_multistreams"] = overwrites.get("audio_multistream") def _get_audio_languages(self, channel_id: str) -> list[str] | None: - """get audio languages from config or channel overwrites""" + """get audio languages from config or channel overwrites. + + Returns None if audio_multistream is not effectively enabled — audio_languages + alone must not force multi-track downloads (Option A / strict mode). + """ + if not self._is_audio_multistream_enabled(channel_id): + return None + overwrites = self.channel_overwrites.get(channel_id, {}) audio_languages = overwrites.get( "audio_languages", diff --git a/backend/video/serializers.py b/backend/video/serializers.py index 2182272a..1134783e 100644 --- a/backend/video/serializers.py +++ b/backend/video/serializers.py @@ -60,6 +60,10 @@ class StreamItemSerializer(serializers.Serializer): index = serializers.IntegerField() type = serializers.ChoiceField(choices=["video", "audio"]) width = serializers.IntegerField(required=False) + language = serializers.CharField(required=False, allow_null=True) + title = serializers.CharField(required=False, allow_null=True) + channels = serializers.IntegerField(required=False, allow_null=True) + channel_layout = serializers.CharField(required=False, allow_null=True) class SubtitleFragmentSerializer(serializers.Serializer): diff --git a/backend/video/src/index.py b/backend/video/src/index.py index e3cccdbd..441f195b 100644 --- a/backend/video/src/index.py +++ b/backend/video/src/index.py @@ -334,7 +334,11 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle): ) def _get_download_container(self) -> str: - """resolve container with channel overwrites""" + """resolve container with channel overwrites. + + MKV is only forced when audio_multistream is effectively enabled. + audio_languages alone must not override the container (Option A / strict). + """ container = self.config["downloads"].get("container", "mp4") channel_overwrites = self.json_data.get("channel", {}).get( "channel_overwrites", {} @@ -342,17 +346,14 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle): if channel_overwrites.get("download_container"): container = channel_overwrites.get("download_container") - # audio_languages or audio_multistream forces mkv for multi-stream support - audio_languages = ( - channel_overwrites.get("audio_languages") - or self.config["downloads"].get("audio_languages") - ) + # audio_multistream being on requires mkv for multi-track muxing. + # Respect channel override false explicitly (suppress global setting). audio_multistream = ( channel_overwrites.get("audio_multistream") if channel_overwrites.get("audio_multistream") is not None else self.config["downloads"].get("audio_multistream") ) - if audio_languages or audio_multistream: + if audio_multistream: container = "mkv" return container diff --git a/backend/video/src/media_streams.py b/backend/video/src/media_streams.py index 93e1f983..5101c9e4 100644 --- a/backend/video/src/media_streams.py +++ b/backend/video/src/media_streams.py @@ -65,12 +65,41 @@ class MediaStreamExtractor: def _extract_audio_metadata(self, stream) -> None: """extract audio metadata""" + tags = stream.get("tags", {}) + + # Bitrate: prefer stream-level, fall back to BPS tag (common in MKV) + bitrate_raw = stream.get("bit_rate") + if not bitrate_raw: + bps_tag = tags.get("BPS") or tags.get("BPS-eng") or tags.get("NUMBER_OF_BYTES") + if bps_tag: + try: + bitrate_raw = int(bps_tag) + except (TypeError, ValueError): + bitrate_raw = 0 + else: + bitrate_raw = 0 + + language = tags.get("language") or tags.get("LANGUAGE") or None + # Normalise "und" (undetermined) to None + if language == "und": + language = None + + track_title = tags.get("title") or tags.get("TITLE") or None + + # Channels: prefer channel_layout label, fall back to channel count + channel_layout = stream.get("channel_layout") + channels = stream.get("channels") + self.metadata.append( { - "bitrate": int(stream.get("bit_rate", 0)), + "bitrate": int(bitrate_raw), "codec": stream.get("codec_name", "undefined"), "index": stream["index"], "type": "audio", + "language": language, + "title": track_title, + "channels": channels, + "channel_layout": channel_layout, } ) diff --git a/frontend/src/pages/ChannelAbout.tsx b/frontend/src/pages/ChannelAbout.tsx index 94c587de..f287b6b6 100644 --- a/frontend/src/pages/ChannelAbout.tsx +++ b/frontend/src/pages/ChannelAbout.tsx @@ -14,6 +14,7 @@ import useIsAdmin from '../functions/useIsAdmin'; import InputConfig from '../components/InputConfig'; import ToggleConfig from '../components/ToggleConfig'; import { useUserConfigStore } from '../stores/UserConfigStore'; +import { useAppSettingsStore } from '../stores/AppSettingsStore'; import { ApiResponseType } from '../functions/APIClient'; export type ChannelBaseOutletContextType = { @@ -35,6 +36,7 @@ type ChannelAboutParams = { const ChannelAbout = () => { const { channelId } = useParams() as ChannelAboutParams; const { userConfig } = useUserConfigStore(); + const { appSettingsConfig } = useAppSettingsStore(); const { setStartNotification } = useOutletContext() as ChannelBaseOutletContextType; const navigate = useNavigate(); const isAdmin = useIsAdmin(); @@ -129,6 +131,11 @@ const ChannelAbout = () => { return 'Channel not found!'; } + // The effective container is the channel override if set, otherwise the global setting. + const globalContainer = appSettingsConfig.downloads.container ?? 'mp4'; + const effectiveContainer = downloadContainer ?? globalContainer; + const isMkvActive = effectiveContainer === 'mkv'; + return ( <> {`TA | Channel: About ${channel.channel_name}`} @@ -342,9 +349,9 @@ const ChannelAbout = () => { {

{audioMultistreamWarning}

)} -
-
-

Audio Languages

+ {isMkvActive && ( +
+
+

Audio Languages

+
+
- -
+ )}

diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index ba3398b0..4d95c61c 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -47,6 +47,10 @@ export type StreamType = { width?: number; height?: number; bitrate: number; + language?: string | null; + title?: string | null; + channels?: number | null; + channel_layout?: string | null; }; export type Subtitles = { diff --git a/frontend/src/pages/Video.tsx b/frontend/src/pages/Video.tsx index abd1e8ef..c305c51c 100644 --- a/frontend/src/pages/Video.tsx +++ b/frontend/src/pages/Video.tsx @@ -19,7 +19,6 @@ import humanFileSize from '../functions/humanFileSize'; import ScrollToTopOnNavigate from '../components/ScrollToTop'; import ChannelOverview from '../components/ChannelOverview'; import deleteVideo from '../api/actions/deleteVideo'; -import capitalizeFirstLetter from '../functions/capitalizeFirstLetter'; import formatDate from '../functions/formatDates'; import formatNumbers from '../functions/formatNumbers'; import queueReindex from '../api/actions/queueReindex'; @@ -465,15 +464,40 @@ const Video = () => { {video.streams && video.streams.map(stream => { + const bitrateDisplay = + stream.bitrate > 0 + ? `${humanFileSize(stream.bitrate, useSiUnits)}/s` + : null; + + if (stream.type === 'video') { + return ( +

+ Video: {stream.codec} + {bitrateDisplay && <> {bitrateDisplay}} + {stream.width && ( + <> + | {stream.width}x{stream.height} + + )} +

+ ); + } + + // Audio stream + const langLabel = stream.title || stream.language?.toUpperCase() || null; + const layoutLabel = stream.channel_layout || (stream.channels ? `${stream.channels}ch` : null); + return (

- {capitalizeFirstLetter(stream.type)}: {stream.codec}{' '} - {humanFileSize(stream.bitrate, useSiUnits)}/s - {stream.width && ( + Audio + {langLabel && <> [{langLabel}]}:{' '} + {stream.codec} + {bitrateDisplay && <> {bitrateDisplay}} + {layoutLabel && ( <> - | {stream.width}x{stream.height} + | {layoutLabel} - )}{' '} + )}

); })}