Detect sidecar subtitle languages and show their flags in the UI

This commit is contained in:
2026-09-23 01:45:04 +00:00
parent c11cbd4250
commit c891bc84a8
7 changed files with 273 additions and 9 deletions
+3 -2
View File
@@ -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"
></span>
<span
@@ -22,11 +22,12 @@
<script setup lang="ts">
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
}>()
@@ -44,6 +44,7 @@
<LanguageFlags
class="language-flags-subs"
:codes="torrent.subtitle_languages"
:external-codes="torrent.external_subtitle_languages"
:compact="compactFlags"
/>
</div>
+1
View File
@@ -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
+168 -4
View File
@@ -16,17 +16,18 @@ const LANGUAGE_TO_COUNTRY: Record<string, string> = {
// 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<string, string> = {
fi: "FI",
fin: "FI",
pl: "PL",
: "PL",
pol: "PL",
cs: "CZ",
ces: "CZ",
cze: "CZ",
@@ -121,6 +122,113 @@ const LANGUAGE_TO_COUNTRY: Record<string, string> = {
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<string, string> = {
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<string, string> = {
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,
+32 -3
View File
@@ -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")),
+67
View File
@@ -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 `<stem>.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 `<stem>.<lang>.<ext>` (e.g. `Movie.esp.srt` ->
``esp``), optionally with flags like ``forced``/``sdh`` after the language.
Bare `<stem>.<ext>` 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.
+1
View File
@@ -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