Enhance language and title extraction from metadata tags

This commit is contained in:
Dakota Gravitt 2026-02-26 00:16:44 -06:00
parent 5bdc56c865
commit 0044ca19a1
3 changed files with 205 additions and 15 deletions

View File

@ -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:

View File

@ -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

View File

@ -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 (