feat(download): enforce strict audio_multistream gating and enrich stream metadata

- 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
This commit is contained in:
Dakota Gravitt 2026-02-24 16:27:09 -06:00
parent d5fc906641
commit 927ed0807d
7 changed files with 107 additions and 29 deletions

View File

@ -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",

View File

@ -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):

View File

@ -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

View File

@ -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,
}
)

View File

@ -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 (
<>
<title>{`TA | Channel: About ${channel.channel_name}`}</title>
@ -342,9 +349,9 @@ const ChannelAbout = () => {
<ToggleConfig
name="audio_multistream"
value={audioMultistream ?? false}
disabled={(downloadContainer ?? 'mp4') !== 'mkv'}
disabled={!isMkvActive}
helperText={
(downloadContainer ?? 'mp4') !== 'mkv'
!isMkvActive
? 'Choose mkv above (or use the global mkv setting) to enable multiple audio tracks.'
: 'Downloads every available audio language for this channel.'
}
@ -358,19 +365,21 @@ const ChannelAbout = () => {
<p className="settings-error">{audioMultistreamWarning}</p>
)}
</div>
<div className="settings-box-wrapper">
<div>
<p>Audio Languages</p>
{isMkvActive && (
<div className="settings-box-wrapper">
<div>
<p>Audio Languages</p>
</div>
<InputConfig
type="text"
name="audio_languages"
value={audioLanguages}
setValue={setAudioLanguages}
oldValue={channel.channel_overwrites?.audio_languages ?? null}
updateCallback={handleUpdateConfig}
/>
</div>
<InputConfig
type="text"
name="audio_languages"
value={audioLanguages}
setValue={setAudioLanguages}
oldValue={channel.channel_overwrites?.audio_languages ?? null}
updateCallback={handleUpdateConfig}
/>
</div>
)}
<div className="settings-box-wrapper">
<div>
<p>

View File

@ -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 = {

View File

@ -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 (
<p key={stream.index}>
Video: {stream.codec}
{bitrateDisplay && <> {bitrateDisplay}</>}
{stream.width && (
<>
<span className="space-carrot">|</span> {stream.width}x{stream.height}
</>
)}
</p>
);
}
// Audio stream
const langLabel = stream.title || stream.language?.toUpperCase() || null;
const layoutLabel = stream.channel_layout || (stream.channels ? `${stream.channels}ch` : null);
return (
<p key={stream.index}>
{capitalizeFirstLetter(stream.type)}: {stream.codec}{' '}
{humanFileSize(stream.bitrate, useSiUnits)}/s
{stream.width && (
Audio
{langLabel && <> [{langLabel}]</>}:{' '}
{stream.codec}
{bitrateDisplay && <> {bitrateDisplay}</>}
{layoutLabel && (
<>
<span className="space-carrot">|</span> {stream.width}x{stream.height}
<span className="space-carrot">|</span> {layoutLabel}
</>
)}{' '}
)}
</p>
);
})}