refactor(downloads): relax mkv enforcement for audio multistream if video does not include additional audio tracks

- Remove API-level validation that rejected `audio_multistream` unless the
container was explicitly set to `mkv` in both app config and channel update endpoints.
- Update yt-dlp option building to only force MKV output when multiple audio tracks are actually selected, instead of always forcing MKV whenever multistream is enabled. This keeps multistream behavior flexible and avoids unnecessary container restrictions for single-track selections.
This commit is contained in:
Dakota Gravitt 2026-02-25 23:53:40 -06:00
parent 776cc2bea1
commit 5bdc56c865
6 changed files with 37 additions and 74 deletions

View File

@ -182,26 +182,6 @@ class AppConfigApiView(ApiBaseView):
serializer = AppConfigSerializer(data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
validated_data = serializer.validated_data
current_config = AppConfig().config
downloads_update = validated_data.get("downloads") or {}
if downloads_update:
audio_multistream = downloads_update.get(
"audio_multistream",
current_config["downloads"]["audio_multistream"],
)
container = downloads_update.get(
"container",
current_config["downloads"]["container"],
)
if audio_multistream and container != "mkv":
error = ErrorResponseSerializer(
{
"error": (
"audio_multistream requires mkv container"
)
}
)
return Response(error.data, status=400)
updated_config = AppConfig().update_config(validated_data)
updated_serializer = AppConfigSerializer(updated_config)
return Response(updated_serializer.data)

View File

@ -10,7 +10,6 @@ from channel.serializers import (
ChannelUpdateSerializer,
)
from channel.src.index import YoutubeChannel, channel_overwrites
from appsettings.src.config import AppConfig
from channel.src.nav import ChannelNav
from common.serializers import ErrorResponseSerializer
from common.src.urlparser import Parser
@ -145,29 +144,6 @@ class ChannelApiView(ApiBaseView):
serializer.is_valid(raise_exception=True)
validated_data = serializer.validated_data
current_overwrites = self.response.get("channel_overwrites", {})
global_container = AppConfig().config["downloads"]["container"]
if overwrites := validated_data.get("channel_overwrites"):
audio_multistream = overwrites.get(
"audio_multistream",
current_overwrites.get("audio_multistream"),
)
container = overwrites.get(
"download_container",
current_overwrites.get("download_container"),
)
if container is None:
container = global_container
if audio_multistream and container != "mkv":
error = ErrorResponseSerializer(
{
"error": (
"audio_multistream requires mkv container"
)
}
)
return Response(error.data, status=400)
subscribed = validated_data.get("channel_subscribed")
if subscribed is not None:
YoutubeChannel(channel_id).change_subscribe(

View File

@ -427,6 +427,7 @@ class VideoDownloader(DownloaderBase):
languages = self._get_audio_languages(channel_id)
hls_formats: dict[str, str] = {} # lang -> format_id for HLS post-process
selected_audio_count = 0
if not languages and self._is_audio_multistream_enabled(channel_id):
# audio_multistream is on but no explicit language list auto-discover
@ -446,16 +447,20 @@ class VideoDownloader(DownloaderBase):
dash_fmt, hls_formats = self._resolve_audio_formats(
formats, languages
)
selected_audio_count = len(dash_fmt) + len(hls_formats)
main_fmt = self._build_main_format(formats, dash_fmt)
if main_fmt:
obs["format"] = main_fmt
if len(dash_fmt) > 1:
obs["audio_multistreams"] = True
obs["merge_output_format"] = "mkv"
obs["outtmpl"] = self.CACHE_DIR + "/download/%(id)s.mkv"
# Only force MKV when multiple audio tracks are actually selected.
if selected_audio_count > 1:
obs["merge_output_format"] = "mkv"
obs["outtmpl"] = self.CACHE_DIR + "/download/%(id)s.mkv"
print(f"{youtube_id}: main format: {main_fmt}")
elif hls_formats:
# all langs are HLS-only; keep default format for video+audio
elif hls_formats and selected_audio_count > 1:
# all selected langs are HLS-only and we have >1 tracks,
# so force MKV for audio track merge.
obs["merge_output_format"] = "mkv"
obs["outtmpl"] = self.CACHE_DIR + "/download/%(id)s.mkv"
@ -465,7 +470,7 @@ class VideoDownloader(DownloaderBase):
return False
# phase 2: merge HLS-only language audio tracks via ffmpeg
if hls_formats:
if hls_formats and selected_audio_count > 1:
main_path = os.path.join(dl_cache, f"{youtube_id}.mkv")
audio_files = []
try:

View File

@ -328,6 +328,24 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle):
def add_file_path(self):
"""build media_url for where file will be located"""
container = self._get_download_container()
# Prefer the actually downloaded cache file extension when available.
# This keeps media_url aligned with effective runtime output when
# multistream is enabled but only a single audio track is selected.
cache_dir = os.path.join(EnvironmentSettings.CACHE_DIR, "download")
preferred_order = [container, "mp4", "mkv"]
seen: set[str] = set()
for ext in preferred_order:
if ext in seen:
continue
seen.add(ext)
candidate = os.path.join(
cache_dir, f"{self.json_data['youtube_id']}.{ext}"
)
if os.path.exists(candidate):
container = ext
break
self.json_data["media_url"] = os.path.join(
self.json_data["channel"]["channel_id"],
self.json_data["youtube_id"] + f".{container}",

View File

@ -14,7 +14,6 @@ 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 = {
@ -36,7 +35,6 @@ 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();
@ -131,11 +129,6 @@ 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>
@ -341,7 +334,7 @@ const ChannelAbout = () => {
}
if (value !== 'mkv' && audioMultistream) {
// Clear multistream FIRST — backend rejects container=mp4 while multistream=true
// Clear multistream first when switching to single-audio container preference.
await handleUpdateConfig('audio_multistream', null);
setAudioMultistream(null);
}
@ -363,11 +356,10 @@ const ChannelAbout = () => {
<ToggleConfig
name="audio_multistream"
value={audioMultistream ?? false}
disabled={!isMkvActive}
helperText={
!isMkvActive
? 'Choose mkv above (or use the global mkv setting) to enable multiple audio tracks.'
: 'Downloads every available audio language for this channel.'
audioMultistream
? 'If multiple audio tracks are selected/found, Tube Archivist will automatically save as mkv. Single-audio downloads keep the selected/effective container.'
: 'Enable to include multiple audio languages when available for this channel.'
}
updateCallback={handleUpdateConfig}
resetCallback={() => {
@ -379,7 +371,7 @@ const ChannelAbout = () => {
<p className="settings-error">{audioMultistreamWarning}</p>
)}
</div>
{isMkvActive && (
{audioMultistream && (
<div className="settings-box-wrapper">
<div>
<p>Audio Languages</p>

View File

@ -535,13 +535,6 @@ const SettingsApplication = () => {
value={downloadContainer}
onChange={async (event: ChangeEvent<HTMLSelectElement>) => {
const value = event.target.value as 'mp4' | 'mkv';
if (value !== 'mkv' && audioMultistream) {
// Clear multistream FIRST — backend rejects container=mp4 while multistream=true
await handleUpdateConfig('downloads.audio_multistream', false);
setAudioMultistream(false);
}
await handleUpdateConfig('downloads.container', value);
setDownloadContainer(value);
}}
@ -568,11 +561,10 @@ const SettingsApplication = () => {
<ToggleConfig
name="downloads.audio_multistream"
value={audioMultistream}
disabled={downloadContainer !== 'mkv'}
helperText={
downloadContainer !== 'mkv'
? 'Select the mkv container to enable multiple audio tracks.'
: 'Downloads all available audio languages when supported.'
audioMultistream
? 'If multiple audio tracks are selected/found, Tube Archivist will automatically save as mkv. Single-audio downloads keep your selected container.'
: 'Enable to include multiple audio languages when available.'
}
updateCallback={handleUpdateConfig}
/>
@ -580,7 +572,7 @@ const SettingsApplication = () => {
<p className="settings-error">{audioMultistreamWarning}</p>
)}
</div>
{audioMultistream && downloadContainer === 'mkv' && (
{audioMultistream && (
<div className="settings-box-wrapper">
<div>
<p>Audio languages</p>