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:
parent
d5fc906641
commit
927ed0807d
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
Loading…
Reference in New Issue