From c891bc84a880f4d24f273290fb86ba078e3b7e0d Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Wed, 23 Sep 2026 01:45:04 +0000 Subject: [PATCH] Detect sidecar subtitle languages and show their flags in the UI --- frontend/src/components/LanguageFlags.vue | 5 +- .../src/components/ReleaseVersionCard.vue | 1 + frontend/src/types.ts | 1 + frontend/src/utils/languageFlags.ts | 172 +++++++++++++++++- mediahive/hivescan/indexer.py | 35 +++- mediahive/hivescan/scanning.py | 67 +++++++ mediahive/models/data.py | 1 + 7 files changed, 273 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/LanguageFlags.vue b/frontend/src/components/LanguageFlags.vue index 68fd0e5..e445d2a 100644 --- a/frontend/src/components/LanguageFlags.vue +++ b/frontend/src/components/LanguageFlags.vue @@ -6,7 +6,7 @@ v-for="entry in flagEntries" :key="entry.countryCode" class="language-flag" - :title="`${entry.countryCode}: ${entry.sourceCodes.join(', ')}`" + :title="formatLanguageFlagTitle(entry, externalCodes)" v-html="entry.svg" > import { computed } from "vue" -import { buildLanguageFlags } from "../utils/languageFlags" +import { buildLanguageFlags, formatLanguageFlagTitle } from "../utils/languageFlags" const props = defineProps<{ label?: string codes: string[] | null | undefined + externalCodes?: string[] | null compact?: boolean }>() diff --git a/frontend/src/components/ReleaseVersionCard.vue b/frontend/src/components/ReleaseVersionCard.vue index 262f13b..6f160a3 100644 --- a/frontend/src/components/ReleaseVersionCard.vue +++ b/frontend/src/components/ReleaseVersionCard.vue @@ -44,6 +44,7 @@ diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 5ef8df5..e0b29d2 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -53,6 +53,7 @@ export interface Torrent { audio: string | null audio_languages: string[] | null subtitle_languages: string[] | null + external_subtitle_languages?: string[] | null hdr?: boolean dovi?: boolean atmos?: boolean diff --git a/frontend/src/utils/languageFlags.ts b/frontend/src/utils/languageFlags.ts index 004bc6a..29dda12 100644 --- a/frontend/src/utils/languageFlags.ts +++ b/frontend/src/utils/languageFlags.ts @@ -16,17 +16,18 @@ const LANGUAGE_TO_COUNTRY: Record = { // Spanish (including LATAM variants collapsed to Spain flag) es: "ES", spa: "ES", + esp: "ES", esl: "ES", spl: "ES", "es-es": "ES", "es-419": "ES", "spa-la": "ES", - // Portuguese + // Portuguese (Brazilian variant collapses to Portugal flag) pt: "PT", por: "PT", "pt-pt": "PT", - "pt-br": "BR", + "pt-br": "PT", // Major European languages fr: "FR", @@ -49,7 +50,7 @@ const LANGUAGE_TO_COUNTRY: Record = { fi: "FI", fin: "FI", pl: "PL", - पोल: "PL", + pol: "PL", cs: "CZ", ces: "CZ", cze: "CZ", @@ -121,6 +122,113 @@ const LANGUAGE_TO_COUNTRY: Record = { eu: "ES", baq: "ES", eus: "ES", + gl: "ES", + glg: "ES", + + // Additional ISO 639-2 codes (bibliographic + terminology) + mk: "MK", + mkd: "MK", + mac: "MK", + et: "EE", + est: "EE", + lv: "LV", + lav: "LV", + lt: "LT", + lit: "LT", + is: "IS", + isl: "IS", + ice: "IS", + ga: "IE", + gle: "IE", + cy: "GB", + cym: "GB", + wel: "GB", + gd: "GB", + gla: "GB", + mt: "MT", + mlt: "MT", + sq: "AL", + sqi: "AL", + alb: "AL", + be: "BY", + bel: "BY", + bs: "BA", + bos: "BA", + scc: "RS", + scr: "HR", + nb: "NO", + nob: "NO", + nn: "NO", + nno: "NO", + kk: "KZ", + kaz: "KZ", + az: "AZ", + aze: "AZ", + hy: "AM", + hye: "AM", + arm: "AM", + ka: "GE", + kat: "GE", + geo: "GE", + uz: "UZ", + uzb: "UZ", + tk: "TM", + tuk: "TM", + tg: "TJ", + tgk: "TJ", + ky: "KG", + kir: "KG", + mn: "MN", + mon: "MN", + bo: "CN", + bod: "CN", + tib: "CN", + my: "MM", + mya: "MM", + bur: "MM", + km: "KH", + khm: "KH", + lo: "LA", + lao: "LA", + si: "LK", + sin: "LK", + ne: "NP", + nep: "NP", + bn: "BD", + ben: "BD", + ta: "IN", + tam: "IN", + te: "IN", + tel: "IN", + kn: "IN", + kan: "IN", + ml: "IN", + mal: "IN", + mr: "IN", + mar: "IN", + gu: "IN", + guj: "IN", + pa: "IN", + pan: "IN", + tl: "PH", + tgl: "PH", + fil: "PH", + af: "ZA", + afr: "ZA", + am: "ET", + amh: "ET", + so: "SO", + som: "SO", + ha: "NG", + hau: "NG", + yo: "NG", + yor: "NG", + ig: "NG", + ibo: "NG", + ku: "TR", + kur: "TR", + ps: "AF", + pus: "AF", } function normalizeLanguageCode(code: string): string { @@ -265,9 +373,13 @@ export function mapLanguageToCountry(code: string): string | null { const direct = LANGUAGE_TO_COUNTRY[normalized] if (direct) return direct - // region-tag style code like en-us / pt-br / es-mx + // region-tag style code like en-us / pt-br / es-mx: variants collapse to + // the base language's host-country flag; only fall back to the region + // itself when the base language is unmapped. const hyphenParts = normalized.split("-") if (hyphenParts.length >= 2) { + const base = LANGUAGE_TO_COUNTRY[hyphenParts[0]] + if (base) return base const region = hyphenParts[hyphenParts.length - 1] if (/^[a-z]{2}$/i.test(region)) { return region.toUpperCase() @@ -341,10 +453,15 @@ export function buildLanguageFlags(codes: string[] | null | undefined): { const LANGUAGE_NAME_OVERRIDES: Record = { eng: "English", spa: "Spanish", + esp: "Spanish", "spa-la": "Spanish", + "es-419": "Spanish", esl: "Spanish", spl: "Spanish", por: "Portuguese", + "pt-br": "Portuguese", + nob: "Norwegian", + nno: "Norwegian", fre: "French", fra: "French", ger: "German", @@ -431,6 +548,53 @@ function summarizeLanguageCodes(codes: string[] | null | undefined): string { return names.join(", ") } +const REGION_NAME_OVERRIDES: Record = { + GB: "UK", + US: "US", +} + +function toRegionName(countryCode: string): string { + const override = REGION_NAME_OVERRIDES[countryCode] + if (override) return override + const display = new Intl.DisplayNames(["en"], { type: "region" }) + return display.of(countryCode) ?? countryCode +} + +export function formatLanguageFlagTitle( + entry: LanguageFlagEntry, + externalCodes?: string[] | null, +): string { + const names: string[] = [] + const variants: string[] = [] + const external = new Set( + (externalCodes ?? []).map((c) => resolveLanguageIdentifier(c)), + ) + let hasExternal = false + for (const code of entry.sourceCodes) { + const normalized = resolveLanguageIdentifier(code) + const base = normalized.split("-", 1)[0] + const name = toLanguageName(base) + if (!names.includes(name)) names.push(name) + // Explicit region tags (en-us, es-419) become parenthesized variants; + // plain codes contribute their host country. + const suffix = normalized.split("-").pop() ?? "" + const region = /^[a-z]{2}$|^\d{3}$/i.test(suffix) && normalized.includes("-") + ? suffix.toUpperCase() + : mapLanguageToCountry(code) + const regionName = region ? toRegionName(region) : null + const variant = external.has(normalized) + ? regionName + ? `${regionName} srt` + : "srt" + : regionName + if (variant && !variants.includes(variant)) variants.push(variant) + if (external.has(normalized)) hasExternal = true + } + const title = names.join(" / ") + if (variants.length > 1 || hasExternal) return `${title} (${variants.join(", ")})` + return title +} + export function formatAudioSubtitleSummary( audioCodes: string[] | null | undefined, subtitleCodes: string[] | null | undefined, diff --git a/mediahive/hivescan/indexer.py b/mediahive/hivescan/indexer.py index b9b1e9a..0f3c0a7 100644 --- a/mediahive/hivescan/indexer.py +++ b/mediahive/hivescan/indexer.py @@ -16,6 +16,7 @@ from mediahive.hivescan.models import ContentType, ParsedContent from mediahive.hivescan.scanning import ( find_cover_image, find_episode_files, + find_external_subtitle_languages, find_metadata_probe_file, find_playable_file, ) @@ -61,6 +62,18 @@ def _infer_hdr10plus(*values: str | None) -> bool: return bool(_HDR10PLUS_RE.search(text)) +def _merge_subtitle_languages( + probed: list[str] | None, + external: list[str], +) -> list[str] | None: + """Union embedded subtitle languages with sidecar-subtitle languages.""" + merged = list(probed or []) + for lang in external: + if lang not in merged: + merged.append(lang) + return merged or None + + def _compact_playable_file(file_key: str, playable_file: str | None) -> str | None: """Store playable paths compactly relative to the file key when possible.""" if not playable_file: @@ -99,6 +112,7 @@ async def _build_torrent_info( probe_target = await find_metadata_probe_file(playable_file) if probe_target: probe_info = await probe_media_info(str(probe_target)) + external_subs = await find_external_subtitle_languages(playable_file) if item.content_hash and item.content_hash.size == 0: item.content_hash.size = await asyncio.to_thread( @@ -125,7 +139,11 @@ async def _build_torrent_info( codec=item.codec, audio=item.audio, audio_languages=probe_info.audio_languages if probe_info else None, - subtitle_languages=probe_info.subtitle_languages if probe_info else None, + subtitle_languages=_merge_subtitle_languages( + probe_info.subtitle_languages if probe_info else None, + external_subs, + ), + external_subtitle_languages=external_subs or None, hdr=probe_info.hdr if probe_info else False, dovi=probe_info.dovi if probe_info else False, atmos=probe_info.atmos if probe_info else False, @@ -207,12 +225,16 @@ async def _collect_episode_files( all_episode_files[key] = [] for file_path, file_size in files: probe = await get_probe(file_path) + external_subs = await find_external_subtitle_languages(file_path) all_episode_files[key].append({ "path": file_path, "size": file_size, "probed_resolution": probe.resolution, "audio_languages": probe.audio_languages, - "subtitle_languages": probe.subtitle_languages, + "subtitle_languages": _merge_subtitle_languages( + probe.subtitle_languages, external_subs + ), + "external_subtitle_languages": external_subs or None, "hdr": probe.hdr, "dovi": probe.dovi, "atmos": probe.atmos, @@ -255,12 +277,18 @@ async def _collect_episode_files( item.content_hash.path, ) size = item.content_hash.size if item.content_hash else 0 + external_subs = await find_external_subtitle_languages( + playable + ) all_episode_files[key].append({ "path": playable, "size": size, "probed_resolution": probe.resolution, "audio_languages": probe.audio_languages, - "subtitle_languages": probe.subtitle_languages, + "subtitle_languages": _merge_subtitle_languages( + probe.subtitle_languages, external_subs + ), + "external_subtitle_languages": external_subs or None, "hdr": probe.hdr, "dovi": probe.dovi, "atmos": probe.atmos, @@ -343,6 +371,7 @@ def _build_episodes_data( audio=f.get("audio"), audio_languages=f.get("audio_languages"), subtitle_languages=f.get("subtitle_languages"), + external_subtitle_languages=f.get("external_subtitle_languages"), hdr=bool(f.get("hdr")), dovi=bool(f.get("dovi")), atmos=bool(f.get("atmos")), diff --git a/mediahive/hivescan/scanning.py b/mediahive/hivescan/scanning.py index 6588c66..0d94478 100644 --- a/mediahive/hivescan/scanning.py +++ b/mediahive/hivescan/scanning.py @@ -28,6 +28,23 @@ VIDEO_EXTENSIONS = { ".m2ts", } +# External subtitle file extensions +SUBTITLE_EXTENSIONS = {".srt", ".ass", ".ssa", ".vtt", ".sub"} + +# Non-language tokens that may follow the language in a sidecar filename +_SUBTITLE_FLAG_TOKENS = {"forced", "sdh", "cc", "hi", "dhi", "commentary", "signs"} + +# ISO 639-1 -> ISO 639-2/B for common sidecar language tags, so they merge +# with the codes ffmpeg reports for embedded tracks. +_ISO_639_1_TO_639_2 = { + "ar": "ara", "cs": "ces", "da": "dan", "de": "deu", "el": "ell", + "en": "eng", "es": "esp", "fi": "fin", "fr": "fra", "he": "heb", + "hi": "hin", "hu": "hun", "id": "ind", "it": "ita", "ja": "jpn", + "ko": "kor", "nl": "nld", "no": "nor", "pl": "pol", "pt": "por", + "ru": "rus", "sv": "swe", "th": "tha", "tr": "tur", "uk": "ukr", + "vi": "vie", "zh": "zho", +} + # Caches for expensive operations. These are per-scan only: the scanner # clears them at the start of every scan. Caching across scans is wrong — # an empty result recorded before a download finished (or during a transient @@ -322,6 +339,56 @@ async def find_playable_file(path: Path) -> str | None: return result +def _sidecar_subtitle_language(video_stem: str, filename: str) -> str | None: + """Language tag from a sidecar subtitle name like `.esp.srt`, if any.""" + if not filename.startswith(video_stem + "."): + return None + suffix = Path(filename).suffix.lower() + if suffix not in SUBTITLE_EXTENSIONS: + return None + middle = filename[len(video_stem) + 1 : -len(suffix)] + tokens = [t for t in middle.split(".") if t] + while tokens and tokens[-1].lower() in _SUBTITLE_FLAG_TOKENS: + tokens.pop() + if not tokens: + return None + code = tokens[-1].lower() + if not code.isalpha() or not 2 <= len(code) <= 3: + return None + code = _ISO_639_1_TO_639_2.get(code, code) + return None if code == "und" else code + + +def _scan_external_subtitle_languages(video_path: Path) -> list[str]: + languages: list[str] = [] + with os.scandir(video_path.parent) as entries: + for entry in entries: + if not entry.is_file(follow_symlinks=False): + continue + lang = _sidecar_subtitle_language(video_path.stem, entry.name) + if lang and lang not in languages: + languages.append(lang) + return languages + + +async def find_external_subtitle_languages(video_path: str | None) -> list[str]: + """Languages of external subtitle files sitting next to a video file. + + Matches sidecars named `..` (e.g. `Movie.esp.srt` -> + ``esp``), optionally with flags like ``forced``/``sdh`` after the language. + Bare `.` files carry no language tag and are ignored. + """ + if not video_path or "://" in video_path or video_path.startswith("concat:"): + return [] + path = Path(video_path) + if path.suffix.lower() not in VIDEO_EXTENSIONS: + return [] + try: + return await asyncio.to_thread(_scan_external_subtitle_languages, path) + except OSError, PermissionError: + return [] + + async def find_metadata_probe_file(playable_path: str | None) -> str | None: """Resolve a path suitable for ffmpeg stream metadata probing. diff --git a/mediahive/models/data.py b/mediahive/models/data.py index c26b3ab..4228165 100644 --- a/mediahive/models/data.py +++ b/mediahive/models/data.py @@ -26,6 +26,7 @@ class Torrent(msgspec.Struct, omit_defaults=True): audio: str | None = None audio_languages: list[str] | None = None subtitle_languages: list[str] | None = None + external_subtitle_languages: list[str] | None = None hdr: bool = False dovi: bool = False atmos: bool = False