diff --git a/backend/channel/serializers.py b/backend/channel/serializers.py index c16b3993..31a7c2d2 100644 --- a/backend/channel/serializers.py +++ b/backend/channel/serializers.py @@ -19,6 +19,7 @@ class ChannelOverwriteSerializer( 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 f68ae00a..e7f862fc 100644 --- a/backend/channel/src/index.py +++ b/backend/channel/src/index.py @@ -268,6 +268,7 @@ class YoutubeChannel(YouTubeItem): "download_format", "download_container", "audio_multistream", + "audio_languages", "autodelete_days", "index_playlists", "integrate_sponsorblock", diff --git a/backend/channel/views.py b/backend/channel/views.py index 7eee11d3..b0cde1b2 100644 --- a/backend/channel/views.py +++ b/backend/channel/views.py @@ -175,7 +175,11 @@ class ChannelApiView(ApiBaseView): ) if overwrites := validated_data.get("channel_overwrites"): - channel_overwrites(channel_id, overwrites) + try: + channel_overwrites(channel_id, overwrites) + except ValueError as err: + error = ErrorResponseSerializer({"error": str(err)}) + return Response(error.data, status=400) if overwrites.get("index_playlists"): index_channel_playlists.delay(channel_id) diff --git a/backend/download/src/yt_dlp_handler.py b/backend/download/src/yt_dlp_handler.py index b0133ffd..2b6360c8 100644 --- a/backend/download/src/yt_dlp_handler.py +++ b/backend/download/src/yt_dlp_handler.py @@ -391,6 +391,27 @@ class VideoDownloader(DownloaderBase): 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, with optional multi-language audio merging""" obs = self.obs.copy() @@ -400,9 +421,20 @@ class VideoDownloader(DownloaderBase): languages = self._get_audio_languages(channel_id) hls_formats: dict[str, str] = {} # lang -> format_id for HLS post-process + 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}") - formats = self._get_formats(youtube_id) + if formats is None: + formats = self._get_formats(youtube_id) if formats: dash_fmt, hls_formats = self._resolve_audio_formats( formats, languages diff --git a/backend/video/src/index.py b/backend/video/src/index.py index b755ac5f..e3cccdbd 100644 --- a/backend/video/src/index.py +++ b/backend/video/src/index.py @@ -342,12 +342,17 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle): if channel_overwrites.get("download_container"): container = channel_overwrites.get("download_container") - # audio_languages forces mkv for multi-stream support + # 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") ) - if audio_languages: + 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: container = "mkv" return container diff --git a/frontend/src/pages/ChannelAbout.tsx b/frontend/src/pages/ChannelAbout.tsx index cb338aa1..94c587de 100644 --- a/frontend/src/pages/ChannelAbout.tsx +++ b/frontend/src/pages/ChannelAbout.tsx @@ -50,6 +50,7 @@ const ChannelAbout = () => { const [downloadContainer, setDownloadContainer] = useState<'mp4' | 'mkv' | null>(null); const [audioMultistream, setAudioMultistream] = useState(null); const [audioMultistreamWarning, setAudioMultistreamWarning] = useState(null); + const [audioLanguages, setAudioLanguages] = useState(null); const [autoDeleteAfter, setAutoDeleteAfter] = useState(null); const [indexPlaylists, setIndexPlaylists] = useState(false); const [enableSponsorblock, setEnableSponsorblock] = useState(null); @@ -76,6 +77,7 @@ const ChannelAbout = () => { channelResponseData?.channel_overwrites?.audio_multistream ?? null, ); setAudioMultistreamWarning(null); + setAudioLanguages(channelResponseData?.channel_overwrites?.audio_languages ?? null); setAutoDeleteAfter(channelResponseData?.channel_overwrites?.autodelete_days ?? null); setIndexPlaylists(channelResponseData?.channel_overwrites?.index_playlists ?? false); setEnableSponsorblock( @@ -356,6 +358,19 @@ const ChannelAbout = () => {

{audioMultistreamWarning}

)} +
+
+

Audio Languages

+
+ +

diff --git a/frontend/src/pages/Channels.tsx b/frontend/src/pages/Channels.tsx index acf5d6f4..700e578d 100644 --- a/frontend/src/pages/Channels.tsx +++ b/frontend/src/pages/Channels.tsx @@ -22,6 +22,7 @@ type ChannelOverwritesType = { download_format: string | null; download_container: 'mp4' | 'mkv' | null; audio_multistream: boolean | null; + audio_languages: string | null; autodelete_days: number | null; index_playlists: boolean | null; integrate_sponsorblock: boolean | null;