diff --git a/backend/appsettings/serializers.py b/backend/appsettings/serializers.py index f26bc73d..be8ccf2a 100644 --- a/backend/appsettings/serializers.py +++ b/backend/appsettings/serializers.py @@ -59,6 +59,8 @@ class AppConfigDownloadsSerializer( extractor_lang = serializers.CharField(allow_null=True) integrate_ryd = serializers.BooleanField() integrate_sponsorblock = serializers.BooleanField() + audio_multistream = serializers.BooleanField() + audio_languages = serializers.CharField(allow_null=True) class AppConfigAppSerializer( diff --git a/backend/appsettings/src/config.py b/backend/appsettings/src/config.py index 8bd81579..f18c49e3 100644 --- a/backend/appsettings/src/config.py +++ b/backend/appsettings/src/config.py @@ -46,6 +46,8 @@ class DownloadsConfigType(TypedDict): extractor_lang: str | None integrate_ryd: bool integrate_sponsorblock: bool + audio_multistream: bool + audio_languages: str | None class ApplicationConfigType(TypedDict): @@ -95,6 +97,8 @@ class AppConfig: "extractor_lang": None, "integrate_ryd": False, "integrate_sponsorblock": False, + "audio_multistream": False, + "audio_languages": None, }, "application": { "enable_snapshot": True, @@ -180,17 +184,17 @@ class AppConfig: def clear_old_keys(self) -> list[str]: """clear old unused keys""" cleared = [] - for key in list(self.config.keys()): + for key, value in self.config.items(): if key not in self.CONFIG_DEFAULTS: # complete key removed - value = self.config.pop(key) + self.config.pop(key) cleared.append(str({key: value})) continue expected_keys = set( self.CONFIG_DEFAULTS[key].keys() # type: ignore ) - is_keys = set(list(self.config[key].keys())) + is_keys = set(self.config[key].keys()) for to_delete in is_keys - expected_keys: self.config[key].pop(to_delete) diff --git a/backend/channel/serializers.py b/backend/channel/serializers.py index d2dc4184..aac0b95d 100644 --- a/backend/channel/serializers.py +++ b/backend/channel/serializers.py @@ -13,6 +13,10 @@ class ChannelOverwriteSerializer( """serialize channel overwrites""" download_format = serializers.CharField(required=False, allow_null=True) + audio_multistream = serializers.BooleanField( + required=False, allow_null=True + ) + audio_languages = serializers.CharField(required=False, allow_null=True) autodelete_days = serializers.IntegerField(required=False, allow_null=True) index_playlists = serializers.BooleanField(required=False, allow_null=True) integrate_sponsorblock = serializers.BooleanField( diff --git a/backend/channel/src/index.py b/backend/channel/src/index.py index 390f9023..93319c0f 100644 --- a/backend/channel/src/index.py +++ b/backend/channel/src/index.py @@ -266,6 +266,8 @@ class YoutubeChannel(YouTubeItem): """set per channel overwrites""" valid_keys = [ "download_format", + "audio_multistream", + "audio_languages", "autodelete_days", "index_playlists", "integrate_sponsorblock", diff --git a/backend/download/src/yt_dlp_handler.py b/backend/download/src/yt_dlp_handler.py index 8aaf0234..91457c2d 100644 --- a/backend/download/src/yt_dlp_handler.py +++ b/backend/download/src/yt_dlp_handler.py @@ -8,6 +8,7 @@ functionality: import os import shutil +import subprocess from datetime import datetime from appsettings.src.config import AppConfig @@ -28,6 +29,7 @@ from playlist.src.index import YoutubePlaylist from video.src.comments import CommentList from video.src.constants import VideoTypeEnum from video.src.index import YoutubeVideo, index_new_video +from yt_dlp.utils import ISO639Utils class DownloaderBase: @@ -156,6 +158,7 @@ class VideoDownloader(DownloaderBase): def _build_obs_basic(self): """initial obs""" self.obs = { + # MP4 is now the single supported output container. "merge_output_format": "mp4", "outtmpl": (self.CACHE_DIR + "/download/%(id)s.mp4"), "progress_hooks": [self._progress_hook], @@ -174,6 +177,8 @@ class VideoDownloader(DownloaderBase): format_sort = self.config["downloads"]["format_sort"] format_sort_list = [i.strip() for i in format_sort.split(",")] self.obs["format_sort"] = format_sort_list + if self.config["downloads"].get("audio_multistream"): + self.obs["audio_multistreams"] = True if self.config["downloads"]["limit_speed"]: self.obs["ratelimit"] = ( self.config["downloads"]["limit_speed"] * 1024 @@ -183,6 +188,261 @@ class VideoDownloader(DownloaderBase): if throttle: self.obs["throttledratelimit"] = throttle * 1024 + def _get_formats(self, youtube_id: str) -> list[dict]: + """extract available formats for a video""" + extract_obs = {"skip_download": True, "quiet": True} + yt_extract = YtWrap(extract_obs, self.config) + response, error = yt_extract.extract( + f"https://www.youtube.com/watch?v={youtube_id}" + ) + if not response or error: + print(f"{youtube_id}: failed to extract formats: {error}") + return [] + return response.get("formats", []) + + @staticmethod + def _resolve_dash_audio_formats( + formats: list[dict], languages: list[str] + ) -> dict[str, str]: + """pick one DASH audio-only format per requested language. + + Keep this intentionally simple: the final format expression stays fully + yt-dlp-driven and we avoid any additional post-merge code paths. + """ + dash_formats: dict[str, str] = {} + + for lang in languages: + dash = [ + f + for f in formats + if (f.get("vcodec") or "none") == "none" + and (f.get("acodec") or "none") != "none" + and (f.get("language") or "").startswith(lang) + and f.get("protocol") in ("https", "http") + ] + if dash: + dash.sort(key=lambda f: f.get("tbr") or 0, reverse=True) + chosen = dash[0] + dash_formats[lang] = chosen["format_id"] + print( + f"[audio_languages] {lang}: DASH audio " + f"{chosen['format_id']} ({chosen.get('acodec')}, " + f"{chosen.get('tbr')}kbps)" + ) + continue + + print(f"[audio_languages] no DASH audio found for: {lang}") + + return dash_formats + + @staticmethod + def _resolve_hls_audio_formats( + formats: list[dict], languages: list[str], dash_formats: dict[str, str] + ) -> dict[str, str]: + """pick one HLS muxed fallback format for languages missing DASH. + + Why this exists: + - Some uploads expose extra languages only via HLS muxed variants. + - We still prefer DASH first, but we keep a fallback list so we can + recover those missing languages. + """ + hls_formats: dict[str, str] = {} + + for lang in languages: + if lang in dash_formats: + # This language is already covered by DASH selection. + continue + + hls = [ + f + for f in formats + if (f.get("acodec") or "none") != "none" + and (f.get("language") or "").startswith(lang) + and "m3u8" in (f.get("protocol") or "") + ] + if not hls: + continue + + # Prefer lower-bitrate HLS variant for fallback tracks. + hls.sort(key=lambda f: f.get("tbr") or 0) + chosen = hls[0] + hls_formats[lang] = chosen["format_id"] + print( + f"[audio_languages] {lang}: HLS fallback " + f"{chosen['format_id']} ({chosen.get('acodec')}, " + f"{chosen.get('tbr')}kbps)" + ) + + return hls_formats + + def _download_hls_audio( + self, youtube_id: str, format_id: str, lang: str + ) -> str | None: + """download one fallback HLS variant to a temporary media file. + + The file can contain video+audio; later we map only the audio stream + during merge, so we don't require audio-only HLS sources. + """ + dl_dir = os.path.join(self.CACHE_DIR, "download") + base_name = f"{youtube_id}_audio_{lang}" + hls_obs = { + "format": format_id, + "outtmpl": os.path.join(dl_dir, f"{base_name}.%(ext)s"), + "audio_multistreams": False, + "quiet": True, + "noprogress": True, + "no_warnings": True, + "noplaylist": True, + } + + print(f"[audio_languages] downloading HLS fallback {lang}: {format_id}") + success, _ = YtWrap(hls_obs, self.config).download(youtube_id) + if not success: + print(f"[audio_languages] failed HLS fallback download for: {lang}") + return None + + # yt-dlp chooses extension by source/container, so discover by prefix. + for fname in os.listdir(dl_dir): + if fname.startswith(base_name + "."): + return os.path.join(dl_dir, fname) + + return None + + @staticmethod + def _normalize_language_code(lang: str) -> str: + """best-effort normalisation for ffmpeg language metadata""" + if not lang: + return "und" + + # Keep the primary subtag for values like "en-US" or "zh-Hans". + primary = lang.split("-")[0].strip().lower() + if not primary: + return "und" + + return primary + + @staticmethod + def _language_title(lang: str) -> str: + """build a human-friendly stream title from a language tag""" + if not lang: + return "Unknown" + + code = VideoDownloader._normalize_language_code(lang) + long_name = ISO639Utils.short2long(code) + if long_name: + return long_name + + return lang.upper() + + @staticmethod + def _count_audio_streams(path: str) -> int: + """count existing audio streams in a media file""" + cmd = [ + "ffprobe", + "-v", + "error", + "-select_streams", + "a", + "-show_entries", + "stream=index", + "-of", + "csv=p=0", + path, + ] + result = subprocess.run(cmd, capture_output=True, text=True, check=False) + if result.returncode != 0: + return 0 + + output = result.stdout.strip() + if not output: + return 0 + + return len(output.splitlines()) + + @staticmethod + def _merge_additional_audio_tracks( + main_path: str, audio_tracks: list[tuple[str, str]] + ) -> bool: + """merge extra fallback tracks into the main MP4 using ffmpeg. + + We keep this narrowly scoped: + - map all streams from the main file, + - append the first audio stream from each fallback file, + - stream-copy only (no re-encode) to reduce complexity. + """ + output_path = main_path + ".merging.mp4" + cmd = ["ffmpeg", "-y", "-i", main_path] + for _, track_path in audio_tracks: + cmd += ["-i", track_path] + + cmd += ["-map", "0"] + for input_idx in range(1, len(audio_tracks) + 1): + cmd += ["-map", f"{input_idx}:a:0"] + + # Label appended tracks so ffprobe/UI can show meaningful language info. + # We offset by existing main-file audio stream count, then annotate each + # appended stream with both language and title metadata. + existing_audio_count = VideoDownloader._count_audio_streams(main_path) + for idx, (lang, _) in enumerate(audio_tracks): + audio_stream_idx = existing_audio_count + idx + language_code = VideoDownloader._normalize_language_code(lang) + language_title = VideoDownloader._language_title(lang) + cmd += [ + f"-metadata:s:a:{audio_stream_idx}", + f"language={language_code}", + f"-metadata:s:a:{audio_stream_idx}", + f"title={language_title}", + ] + + cmd += ["-c", "copy", output_path] + + print(f"[audio_languages] ffmpeg merge: {' '.join(cmd)}") + result = subprocess.run(cmd, capture_output=True) + if result.returncode != 0: + err = result.stderr.decode("utf-8", errors="replace") + print(f"[audio_languages] ffmpeg merge failed: {err}") + try: + os.remove(output_path) + except FileNotFoundError: + pass + return False + + os.replace(output_path, main_path) + return True + + @staticmethod + def _build_main_format( + formats: list[dict], dash_lang_formats: dict + ) -> str | None: + """build DASH-only format string: bestvideo + DASH audio per language""" + video_only = [ + f + for f in formats + if (f.get("vcodec") or "none") != "none" + and (f.get("acodec") or "none") == "none" + ] + if not video_only: + print("[audio_languages] no video-only formats found") + return None + + video_only.sort( + key=lambda f: (f.get("height") or 0, f.get("tbr") or 0), + reverse=True, + ) + best_video = video_only[0] + print( + f"[audio_languages] best video: {best_video['format_id']} " + f"({best_video.get('height')}p, {best_video.get('vcodec')})" + ) + + if not dash_lang_formats: + return None + + parts = [best_video["format_id"]] + list(dash_lang_formats.values()) + format_str = "+".join(parts) + print(f"[audio_languages] main format string: {format_str}") + return format_str + def _build_obs_postprocessors(self): """add postprocessor to obs""" postprocessors = [] @@ -203,26 +463,154 @@ class VideoDownloader(DownloaderBase): overwrites = self.channel_overwrites.get(channel_id) if overwrites and overwrites.get("download_format"): obs["format"] = overwrites.get("download_format") + if overwrites and overwrites.get("audio_multistream") is not None: + obs["audio_multistreams"] = overwrites.get("audio_multistream") + + # Keep output container mp4-only regardless of channel/global values. + obs["merge_output_format"] = "mp4" + obs["outtmpl"] = self.CACHE_DIR + "/download/%(id)s.mp4" + + def _get_audio_languages(self, channel_id: str) -> list[str] | None: + """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", + self.config["downloads"].get("audio_languages"), + ) + if not audio_languages: + return None + + return [ + lang.strip() + for lang in audio_languages.split(",") + if lang.strip() + ] + + def _is_audio_multistream_enabled(self, channel_id: str) -> bool: + """return True if audio_multistream is enabled globally or via channel overwrite""" + overwrites = self.channel_overwrites.get(channel_id, {}) + if "audio_multistream" in overwrites and overwrites["audio_multistream"] is not None: + return bool(overwrites["audio_multistream"]) + return bool(self.config["downloads"].get("audio_multistream")) + + @staticmethod + def _discover_audio_languages(formats: list[dict]) -> list[str]: + """collect all unique language codes from available audio formats""" + seen: set[str] = set() + langs: list[str] = [] + for f in formats: + if (f.get("acodec") or "none") == "none": + continue + lang = (f.get("language") or "").strip() + if lang and lang not in seen: + seen.add(lang) + langs.append(lang) + return langs def _dl_single_vid(self, youtube_id: str, channel_id: str) -> bool: - """download single video""" + """download single video with optional multi-language audio selection""" obs = self.obs.copy() self._set_overwrites(obs, channel_id) dl_cache = os.path.join(self.CACHE_DIR, "download") + languages = self._get_audio_languages(channel_id) + hls_formats_to_merge: dict[str, str] = {} + + if not languages and self._is_audio_multistream_enabled(channel_id): + # audio_multistream is on but no explicit language list – auto-discover + print(f"{youtube_id}: audio_multistream enabled, auto-discovering languages") + formats = self._get_formats(youtube_id) + if formats: + languages = self._discover_audio_languages(formats) + print(f"{youtube_id}: discovered audio languages: {languages}") + else: + formats = None # will be fetched below if needed + + if languages: + print(f"{youtube_id}: applying audio languages {languages}") + if formats is None: + formats = self._get_formats(youtube_id) + if formats: + # Build yt-dlp format string using DASH audio tracks only. + # If no DASH tracks are found, fall back to the normal format selection. + dash_fmt = self._resolve_dash_audio_formats(formats, languages) + hls_fmt = self._resolve_hls_audio_formats( + formats, languages, dash_fmt + ) + selected_audio_count = len(dash_fmt) + len(hls_fmt) + + # DASH-first: use bestvideo + selected DASH audios as main file. + main_fmt = self._build_main_format(formats, dash_fmt) + if main_fmt: + obs["format"] = main_fmt + # If we selected more than one total audio track (DASH+HLS), + # keep yt-dlp multistream flag enabled for the main pass. + if selected_audio_count > 1: + obs["audio_multistreams"] = True + print(f"{youtube_id}: main format: {main_fmt}") + + # Any languages that only exist as HLS are merged afterward. + hls_formats_to_merge = hls_fmt + elif hls_fmt: + # No DASH audio matched. Use first HLS language as the main + # download to ensure at least one requested language lands + # in the base file; merge remaining HLS tracks afterward. + first_lang, first_fmt = next(iter(hls_fmt.items())) + obs["format"] = first_fmt + hls_formats_to_merge = { + lang: fmt + for lang, fmt in hls_fmt.items() + if lang != first_lang + } + print( + f"{youtube_id}: no DASH main format, using HLS " + f"{first_lang}:{first_fmt} as base" + ) + success, message = YtWrap(obs, self.config).download(youtube_id) if not success: self._handle_error(youtube_id, message) + return False + + if hls_formats_to_merge: + # Post-step for HLS-only languages: download temporary fallback files + # and append their audio streams to the MP4 generated above. + main_path = os.path.join(dl_cache, f"{youtube_id}.mp4") + audio_tracks: list[tuple[str, str]] = [] + try: + for lang, fmt_id in hls_formats_to_merge.items(): + track_path = self._download_hls_audio( + youtube_id, fmt_id, lang + ) + if track_path: + audio_tracks.append((lang, track_path)) + + if audio_tracks: + self._merge_additional_audio_tracks(main_path, audio_tracks) + finally: + for _, track_path in audio_tracks: + try: + os.remove(track_path) + except FileNotFoundError: + pass if self.obs["writethumbnail"]: # webp files don't get cleaned up automatically all_cached = ignore_filelist(os.listdir(dl_cache)) - to_clean = [i for i in all_cached if not i.endswith(".mp4")] + target_ext = os.path.splitext(obs["outtmpl"])[-1] + to_clean = [i for i in all_cached if not i.endswith(target_ext)] for file_name in to_clean: file_path = os.path.join(dl_cache, file_name) os.remove(file_path) - return success + return True @staticmethod def _handle_error(youtube_id, message): @@ -243,7 +631,8 @@ class VideoDownloader(DownloaderBase): if host_uid and host_gid: os.chown(folder, host_uid, host_gid) # move media file - media_file = vid_dict["youtube_id"] + ".mp4" + media_ext = os.path.splitext(vid_dict["media_url"])[-1] + media_file = vid_dict["youtube_id"] + media_ext old_path = os.path.join(self.CACHE_DIR, "download", media_file) new_path = os.path.join(self.MEDIA_DIR, vid_dict["media_url"]) # move media file and fix permission diff --git a/frontend/src/api/loader/loadAppsettingsConfig.ts b/frontend/src/api/loader/loadAppsettingsConfig.ts index 47b1ba3a..223b5f86 100644 --- a/frontend/src/api/loader/loadAppsettingsConfig.ts +++ b/frontend/src/api/loader/loadAppsettingsConfig.ts @@ -27,6 +27,8 @@ export type AppSettingsConfigType = { extractor_lang: string | null; integrate_ryd: boolean; integrate_sponsorblock: boolean; + audio_multistream: boolean; + audio_languages: string | null; }; application: { enable_snapshot: boolean; diff --git a/frontend/src/pages/ChannelAbout.tsx b/frontend/src/pages/ChannelAbout.tsx index 3e966182..70e05b1c 100644 --- a/frontend/src/pages/ChannelAbout.tsx +++ b/frontend/src/pages/ChannelAbout.tsx @@ -13,7 +13,9 @@ import updateChannelOverwrites from '../api/actions/updateChannelOverwrite'; import useIsAdmin from '../functions/useIsAdmin'; import InputConfig from '../components/InputConfig'; import ToggleConfig from '../components/ToggleConfig'; +import AudioLanguageSelector from '../components/AudioLanguageSelector'; import { useUserConfigStore } from '../stores/UserConfigStore'; +import { useAppSettingsStore } from '../stores/AppSettingsStore'; import { ApiResponseType } from '../functions/APIClient'; export type ChannelBaseOutletContextType = { @@ -35,6 +37,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(); @@ -47,6 +50,9 @@ const ChannelAbout = () => { const [channelResponse, setChannelResponse] = useState>(); const [downloadFormat, setDownloadFormat] = useState(null); + const [audioMultistreams, setAudioMultistreams] = useState(null); + const [audioMultistreamsWarning, setAudioMultistreamsWarning] = useState(null); + const [audioLanguages, setAudioLanguages] = useState(null); const [autoDeleteAfter, setAutoDeleteAfter] = useState(null); const [indexPlaylists, setIndexPlaylists] = useState(false); const [enableSponsorblock, setEnableSponsorblock] = useState(null); @@ -57,6 +63,8 @@ const ChannelAbout = () => { const { data: channelResponseData } = channelResponse ?? {}; const channel = channelResponseData; + const globalAudioMultistream = appSettingsConfig.downloads.audio_multistream; + const effectiveAudioMultistream = audioMultistreams ?? globalAudioMultistream; useEffect(() => { (async () => { @@ -66,6 +74,11 @@ const ChannelAbout = () => { setChannelResponse(channelResponse); setDownloadFormat(channelResponseData?.channel_overwrites?.download_format ?? null); + setAudioMultistreams( + channelResponseData?.channel_overwrites?.audio_multistream ?? null, + ); + setAudioMultistreamsWarning(null); + setAudioLanguages(channelResponseData?.channel_overwrites?.audio_languages ?? null); setAutoDeleteAfter(channelResponseData?.channel_overwrites?.autodelete_days ?? null); setIndexPlaylists(channelResponseData?.channel_overwrites?.index_playlists ?? false); setEnableSponsorblock( @@ -91,7 +104,15 @@ const ChannelAbout = () => { configValue: string | boolean | number | null, ) => { if (!channel) return; - await updateChannelOverwrites(channel.channel_id, configKey, configValue); + const response = await updateChannelOverwrites(channel.channel_id, configKey, configValue); + if (response?.error?.error) { + setAudioMultistreamsWarning(response.error.error); + return; + } + setAudioMultistreamsWarning(null); + if (configKey === 'audio_multistream') { + setAudioMultistreams(configValue === null ? null : Boolean(configValue)); + } setRefresh(true); }; @@ -270,6 +291,10 @@ const ChannelAbout = () => {
  • Once you click on Configure, this will activate sponsorblock settings.
  • +
  • + Enable multistream audio to download all available audio languages for this + channel. +
  • )} @@ -286,6 +311,41 @@ const ChannelAbout = () => { updateCallback={handleUpdateConfig} /> +
    +
    +

    Enable multistream audio

    +
    + { + handleUpdateConfig('audio_multistream', null); + setAudioMultistreams(null); + }} + /> + {audioMultistreamsWarning && ( +

    {audioMultistreamsWarning}

    + )} +
    + {effectiveAudioMultistream && ( +
    +
    +

    Audio Languages

    +
    + +
    + )}

    diff --git a/frontend/src/pages/Channels.tsx b/frontend/src/pages/Channels.tsx index e5b7212d..40d74aeb 100644 --- a/frontend/src/pages/Channels.tsx +++ b/frontend/src/pages/Channels.tsx @@ -20,6 +20,8 @@ import { ViewStylesEnum, ViewStylesType } from '../configuration/constants/ViewS type ChannelOverwritesType = { download_format: string | null; + audio_multistream: boolean | null; + audio_languages: string | null; autodelete_days: number | null; index_playlists: boolean | null; integrate_sponsorblock: boolean | null; diff --git a/frontend/src/pages/SettingsApplication.tsx b/frontend/src/pages/SettingsApplication.tsx index d5a56e9f..da1c295b 100644 --- a/frontend/src/pages/SettingsApplication.tsx +++ b/frontend/src/pages/SettingsApplication.tsx @@ -13,6 +13,7 @@ import updateAppsettingsConfig from '../api/actions/updateAppsettingsConfig'; import loadApiToken from '../api/loader/loadApiToken'; import InputConfig from '../components/InputConfig'; import ToggleConfig from '../components/ToggleConfig'; +import AudioLanguageSelector from '../components/AudioLanguageSelector'; import updateCookie from '../api/actions/updateCookie'; import loadCookie, { CookieStateType } from '../api/loader/loadCookie'; import deleteCookie from '../api/actions/deleteCookie'; @@ -56,6 +57,9 @@ const SettingsApplication = () => { const [downloadsFormatSort, setDownloadsFormatSort] = useState(null); const [downloadsExtractorLang, setDownloadsExtractorLang] = useState(null); const [embedMetadata, setEmbedMetadata] = useState(false); + const [audioMultistreams, setAudioMultistreams] = useState(false); + const [audioLanguages, setAudioLanguages] = useState(null); + const [audioMultistreamsWarning, setAudioMultistreamsWarning] = useState(null); // Subtitles const [subtitleLang, setSubtitleLang] = useState(null); @@ -112,6 +116,9 @@ const SettingsApplication = () => { setDownloadsFormatSort(appSettingsConfigData?.downloads.format_sort || null); setDownloadsExtractorLang(appSettingsConfigData?.downloads.extractor_lang || null); setEmbedMetadata(appSettingsConfigData?.downloads.add_metadata || false); + setAudioMultistreams(appSettingsConfigData?.downloads.audio_multistream || false); + setAudioLanguages(appSettingsConfigData?.downloads.audio_languages || null); + setAudioMultistreamsWarning(null); // Subtitles setSubtitleLang(appSettingsConfigData?.downloads.subtitle || null); @@ -147,7 +154,12 @@ const SettingsApplication = () => { ) => { const [group, key] = configKey.split('.'); const updatedConfig = { [group]: { [key]: configValue } } as Partial; - await updateAppsettingsConfig(updatedConfig); + const response = await updateAppsettingsConfig(updatedConfig); + if (response?.error?.error) { + setAudioMultistreamsWarning(response.error.error); + return; + } + setAudioMultistreamsWarning(null); setRefresh(true); }; @@ -463,8 +475,12 @@ const SettingsApplication = () => {

  • - Embedding metadata adds additional metadata and thumbnails directly to the mp4 - file. + Embedding metadata adds additional metadata and thumbnails directly to mp4 + files. +
  • +
  • + Enable multistream audio to download multiple audio languages (maps to{' '} + --audio-multistreams). This increases file size.
  • @@ -518,6 +534,33 @@ const SettingsApplication = () => { updateCallback={handleUpdateConfig} />
    +
    +
    +

    Enable multistream audio

    +
    + + {audioMultistreamsWarning && ( +

    {audioMultistreamsWarning}

    + )} +
    + {audioMultistreams && ( +
    +
    +

    Audio languages

    +
    + +
    + )}

    Subtitles