Files
Futonizer/Services/SubtitleLanguageHelper.cs
T
l4kr c7c125d40b Match and mux in dropped external subtitles
Drag in subtitle files alongside .mkv files and they're matched to
the right video by episode code (S04E06, zero-padding and separator
insensitive, plus 4x06 as a fallback) and muxed in as extra subtitle
tracks. Any subtitle format mkvmerge accepts is recognized: SRT,
(S)SA, VobSub, WebVTT, PGS/SUP, USF, SAMI, TTML/DFXP, STL, Kate.

Matching works regardless of drop order — subtitles dropped before
their video are held pending until a matching video shows up, and
vice versa. Language, forced, and SDH flags are guessed from common
filename tokens (e.g. "eng", "forced", "sdh") to set the muxed
track's language/name/flags; unmatched subtitles are reported in the
status bar.
2026-07-08 10:37:56 +02:00

106 lines
5.0 KiB
C#

using System.IO;
namespace Futonizer.Services;
/// <summary>
/// Result of scanning an external subtitle's filename for language and
/// display hints.
/// </summary>
public readonly record struct SubtitleLanguageInfo(string Language, bool Forced, bool HearingImpaired, string TrackName);
/// <summary>
/// Best-effort language/flag detection for external subtitle files, based on
/// common filename tokens (e.g. "Show.S04E06.eng.srt", "Show.S04E06.forced.srt").
/// Never throws — falls back to an "und" (undetermined) language with no
/// track name when nothing recognisable is found.
/// </summary>
public static class SubtitleLanguageHelper
{
private static readonly Dictionary<string, string> TokenToIso6392 = new(StringComparer.OrdinalIgnoreCase)
{
["en"] = "eng", ["eng"] = "eng", ["english"] = "eng",
["ja"] = "jpn", ["jp"] = "jpn", ["jpn"] = "jpn", ["japanese"] = "jpn",
["es"] = "spa", ["spa"] = "spa", ["spanish"] = "spa", ["esp"] = "spa",
["fr"] = "fre", ["fre"] = "fre", ["fra"] = "fre", ["french"] = "fre",
["de"] = "ger", ["ger"] = "ger", ["deu"] = "ger", ["german"] = "ger",
["it"] = "ita", ["ita"] = "ita", ["italian"] = "ita",
["pt"] = "por", ["por"] = "por", ["portuguese"] = "por", ["ptbr"] = "por",
["ru"] = "rus", ["rus"] = "rus", ["russian"] = "rus",
["zh"] = "chi", ["chi"] = "chi", ["zho"] = "chi", ["chinese"] = "chi", ["cmn"] = "chi", ["yue"] = "chi",
["ko"] = "kor", ["kor"] = "kor", ["korean"] = "kor",
["ar"] = "ara", ["ara"] = "ara", ["arabic"] = "ara",
["nl"] = "dut", ["dut"] = "dut", ["nld"] = "dut", ["dutch"] = "dut",
["sv"] = "swe", ["swe"] = "swe", ["swedish"] = "swe",
["no"] = "nor", ["nor"] = "nor", ["norwegian"] = "nor",
["da"] = "dan", ["dan"] = "dan", ["danish"] = "dan",
["fi"] = "fin", ["fin"] = "fin", ["finnish"] = "fin",
["pl"] = "pol", ["pol"] = "pol", ["polish"] = "pol",
["tr"] = "tur", ["tur"] = "tur", ["turkish"] = "tur",
["vi"] = "vie", ["vie"] = "vie", ["vietnamese"] = "vie",
["th"] = "tha", ["tha"] = "tha", ["thai"] = "tha",
["id"] = "ind", ["ind"] = "ind", ["indonesian"] = "ind",
["he"] = "heb", ["heb"] = "heb", ["hebrew"] = "heb",
["hin"] = "hin", ["hindi"] = "hin",
["cs"] = "cze", ["cze"] = "cze", ["ces"] = "cze", ["czech"] = "cze",
["el"] = "gre", ["gre"] = "gre", ["ell"] = "gre", ["greek"] = "gre",
["hu"] = "hun", ["hun"] = "hun", ["hungarian"] = "hun",
["ro"] = "rum", ["rum"] = "rum", ["ron"] = "rum", ["romanian"] = "rum",
["uk"] = "ukr", ["ukr"] = "ukr", ["ukrainian"] = "ukr",
};
private static readonly Dictionary<string, string> Iso6392ToDisplayName = new(StringComparer.OrdinalIgnoreCase)
{
["eng"] = "English", ["jpn"] = "Japanese", ["spa"] = "Spanish", ["fre"] = "French",
["ger"] = "German", ["ita"] = "Italian", ["por"] = "Portuguese", ["rus"] = "Russian",
["chi"] = "Chinese", ["kor"] = "Korean", ["ara"] = "Arabic", ["dut"] = "Dutch",
["swe"] = "Swedish", ["nor"] = "Norwegian", ["dan"] = "Danish", ["fin"] = "Finnish",
["pol"] = "Polish", ["tur"] = "Turkish", ["vie"] = "Vietnamese", ["tha"] = "Thai",
["ind"] = "Indonesian", ["heb"] = "Hebrew", ["hin"] = "Hindi", ["cze"] = "Czech",
["gre"] = "Greek", ["hun"] = "Hungarian", ["rum"] = "Romanian", ["ukr"] = "Ukrainian",
};
private static readonly char[] TokenSeparators = { '.', '_', '-', ' ', '(', ')', '[', ']' };
public static SubtitleLanguageInfo Detect(string fileName)
{
string stem = Path.GetFileNameWithoutExtension(fileName);
var tokens = stem.Split(TokenSeparators, StringSplitOptions.RemoveEmptyEntries);
string language = "und";
bool forced = false;
bool hearingImpaired = false;
foreach (var token in tokens)
{
if (string.Equals(token, "forced", StringComparison.OrdinalIgnoreCase))
{
forced = true;
continue;
}
if (string.Equals(token, "sdh", StringComparison.OrdinalIgnoreCase)
|| string.Equals(token, "cc", StringComparison.OrdinalIgnoreCase))
{
hearingImpaired = true;
continue;
}
if (language == "und" && TokenToIso6392.TryGetValue(token, out var iso))
language = iso;
}
string trackName = string.Empty;
if (Iso6392ToDisplayName.TryGetValue(language, out var displayName))
trackName = displayName;
if (forced || hearingImpaired)
{
var suffixes = new List<string>();
if (forced) suffixes.Add("Forced");
if (hearingImpaired) suffixes.Add("SDH");
string suffix = $" ({string.Join(", ", suffixes)})";
trackName = string.IsNullOrEmpty(trackName) ? suffix.Trim(' ', '(', ')') : trackName + suffix;
}
return new SubtitleLanguageInfo(language, forced, hearingImpaired, trackName);
}
}