Enhance language and title extraction from metadata tags
This commit is contained in:
parent
5bdc56c865
commit
0044ca19a1
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
Loading…
Reference in New Issue