diff --git a/backend/download/src/yt_dlp_handler.py b/backend/download/src/yt_dlp_handler.py index 80dc383d..ec055c1c 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: @@ -317,18 +319,84 @@ class VideoDownloader(DownloaderBase): return None @staticmethod - def _merge_audio_tracks(main_path: str, audio_files: list[str]) -> bool: - """ffmpeg-merge additional audio tracks into the main MKV file""" - import subprocess + 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 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_audio_tracks( + main_path: str, audio_tracks: list[tuple[str, str]] + ) -> bool: + """ffmpeg-merge additional audio tracks into the main MKV file""" output_path = main_path + ".merging.mkv" cmd = ["ffmpeg", "-y", "-i", main_path] - for af in audio_files: + for _, af in audio_tracks: cmd += ["-i", af] + # copy all streams from main + audio-only from each extra file cmd += ["-map", "0"] - for i in range(1, len(audio_files) + 1): - cmd += ["-map", f"{i}:a"] + + for i in range(1, len(audio_tracks) + 1): + cmd += ["-map", f"{i}:a:0"] + + 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)}") @@ -472,16 +540,17 @@ class VideoDownloader(DownloaderBase): # phase 2: merge HLS-only language audio tracks via ffmpeg if hls_formats and selected_audio_count > 1: main_path = os.path.join(dl_cache, f"{youtube_id}.mkv") - audio_files = [] + audio_tracks: list[tuple[str, str]] = [] try: for lang, fmt_id in hls_formats.items(): af = self._download_hls_audio(youtube_id, fmt_id, lang) if af: - audio_files.append(af) - if audio_files: - self._merge_audio_tracks(main_path, audio_files) + audio_tracks.append((lang, af)) + + if audio_tracks: + self._merge_audio_tracks(main_path, audio_tracks) finally: - for af in audio_files: + for _, af in audio_tracks: try: os.remove(af) except FileNotFoundError: diff --git a/backend/video/src/media_streams.py b/backend/video/src/media_streams.py index 5101c9e4..fc0664fe 100644 --- a/backend/video/src/media_streams.py +++ b/backend/video/src/media_streams.py @@ -79,12 +79,30 @@ class MediaStreamExtractor: else: bitrate_raw = 0 - language = tags.get("language") or tags.get("LANGUAGE") or None + language = ( + tags.get("language") + or tags.get("LANGUAGE") + or tags.get("Language") + or tags.get("lang") + or tags.get("LANG") + or None + ) # Normalise "und" (undetermined) to None - if language == "und": + if isinstance(language, str): + language = language.strip() + + if language and language.lower() == "und": language = None - track_title = tags.get("title") or tags.get("TITLE") or None + track_title = ( + tags.get("title") + or tags.get("TITLE") + or tags.get("Title") + or tags.get("handler_name") + or tags.get("HANDLER_NAME") + or None + ) + track_title = self._clean_audio_title(track_title) # Channels: prefer channel_layout label, fall back to channel count channel_layout = stream.get("channel_layout") @@ -103,6 +121,28 @@ class MediaStreamExtractor: } ) + @staticmethod + def _clean_audio_title(track_title: str | None) -> str | None: + """remove noisy/generic audio titles that aren't useful for UI labels""" + if not track_title: + return None + + cleaned = track_title.strip() + if not cleaned: + return None + + lower = cleaned.lower() + noisy_titles = { + "iso media file produced by google inc.", + "soundhandler", + "iso media", + } + + if lower in noisy_titles: + return None + + return cleaned + def get_file_size(self) -> int: """get filesize in bytes""" return stat(self.media_path).st_size diff --git a/frontend/src/pages/Video.tsx b/frontend/src/pages/Video.tsx index c305c51c..a2c66dd1 100644 --- a/frontend/src/pages/Video.tsx +++ b/frontend/src/pages/Video.tsx @@ -45,6 +45,82 @@ import { ApiResponseType } from '../functions/APIClient'; import VideoThumbnail from '../components/VideoThumbail'; import { ViewStylesEnum, ViewStylesType } from '../configuration/constants/ViewStyle'; +const GENERIC_AUDIO_TITLES = new Set([ + 'iso media file produced by google inc.', + 'soundhandler', + 'iso media', +]); + +const getLanguageLabel = (language?: string | null): string | null => { + if (!language) { + return null; + } + + const raw = language.trim(); + if (!raw) { + return null; + } + + const normalized = raw.split(/[-_]/)[0]; + const code = raw.toUpperCase(); + + try { + const DisplayNames = ( + Intl as unknown as { + DisplayNames?: new ( + locales?: string | string[], + options?: Intl.DisplayNamesOptions, + ) => Intl.DisplayNames; + } + ).DisplayNames; + + if (DisplayNames) { + const displayName = new DisplayNames([navigator.language || 'en', 'en'], { + type: 'language', + }).of(normalized); + + if (displayName && displayName.toLowerCase() !== normalized.toLowerCase()) { + return `${code} - ${displayName}`; + } + } + } catch { + // no-op; fallback to language code + } + + return code; +}; + +const getUsefulAudioTitle = ( + title?: string | null, + language?: string | null, +): string | null => { + if (!title) { + return null; + } + + const cleaned = title.trim(); + if (!cleaned) { + return null; + } + + const lower = cleaned.toLowerCase(); + if (GENERIC_AUDIO_TITLES.has(lower)) { + return null; + } + + const langRaw = language?.trim().toLowerCase(); + if (langRaw && (lower === langRaw || lower === langRaw.split(/[-_]/)[0])) { + return null; + } + + // Filter code-like titles (e.g. jpn, en) that duplicate language context. + if (/^[a-z]{2,3}$/i.test(cleaned)) { + return null; + } + + return cleaned; +}; + const isInPlaylist = (videoId: string, playlist: PlaylistType) => { return playlist.playlist_entries.some(entry => { return entry.youtube_id === videoId; @@ -484,7 +560,12 @@ const Video = () => { } // Audio stream - const langLabel = stream.title || stream.language?.toUpperCase() || null; + const languageLabel = getLanguageLabel(stream.language); + const titleLabel = getUsefulAudioTitle(stream.title, stream.language); + const langLabel = + languageLabel && titleLabel + ? `${languageLabel} (${titleLabel})` + : languageLabel || titleLabel || null; const layoutLabel = stream.channel_layout || (stream.channels ? `${stream.channels}ch` : null); return (