using System.Globalization;
using System.Text.RegularExpressions;
namespace Flaro.Core.Library;
public sealed record MovieName(string Title, int? Year);
public sealed record EpisodeName(string SeriesTitle, int Season, int Episode, string? EpisodeTitle);
/// Macht aus einem Dateinamen wieder Titel, Jahr, Staffel und Folge.
///
///
///
/// Hier scheitern Medienserver in der Praxis. Was auf der Platte liegt,
/// heisst nicht „Der Fall", sondern
/// Notruf.Hafenkante.S01E01.German.1080p.WEB-DL.x264-GRP.mkv, und was
/// davon Titel ist und was Beiwerk, steht nirgends. Rät man falsch, sucht die
/// Metadatenquelle nach „Notruf Hafenkante German 1080p" und findet nichts —
/// der Titel bleibt dann für immer der Dateiname.
///
///
/// Das Verfahren: abschneiden, nicht herauslösen. Alles ab dem ersten
/// Beiwerk-Merkmal fliegt weg. Einzelne Tags zu entfernen und den Rest
/// zusammenzuschieben klingt gründlicher, lässt aber bei jedem unbekannten Tag
/// Bruchstücke im Titel stehen — und unbekannte Tags gibt es immer.
///
///
public static partial class FileNames
{
/// Merkmale, ab denen nur noch Technik kommt.
///
///
/// Nicht vollständig und kann es nicht sein. Es muss nur das erste treffen
/// — alles dahinter fällt ohnehin weg.
///
private static readonly HashSet Beiwerk = new(StringComparer.OrdinalIgnoreCase)
{
// Quelle
"bluray", "blu-ray", "bdrip", "brrip", "bdremux", "remux", "dvdrip", "dvd", "dvdr",
"webrip", "web-dl", "webdl", "web", "hdtv", "pdtv", "hdrip", "cam", "ts", "tc", "r5",
"uhdbd", "uhd", "hddvd",
// Bild
"x264", "x265", "h264", "h265", "hevc", "avc", "xvid", "divx", "av1", "mpeg2", "vc1",
"hdr", "hdr10", "hdr10plus", "dolbyvision", "dovi", "sdr", "10bit", "8bit",
// Ton
"aac", "ac3", "eac3", "dts", "dtshd", "dts-hd", "truehd", "atmos", "flac", "opus",
"mp3", "dd", "ddp", "dd5", "ddp5", "lpcm", "pcm",
// Fassung
"proper", "repack", "rerip", "extended", "unrated", "uncut", "remastered", "imax",
"theatrical", "limited", "internal", "complete", "retail", "subbed", "dubbed",
// Sprache und Herkunft, wie die Szene sie schreibt
"german", "englisch", "english", "multi", "dual", "dl", "ger", "eng", "vo", "vost",
"omu", "subs", "sub",
};
/// Auflösungen wie 1080p, 2160p, 720i — und 4K.
[GeneratedRegex(@"^\d{3,4}[pi]$|^[48]k$", RegexOptions.IgnoreCase)]
private static partial Regex Auflösung();
/// S01E02, s1e2, S01.E02, S01E02E03 — die erste Folge zählt.
[GeneratedRegex(@"\bS(?\d{1,3})[\s._-]*E(?\d{1,4})", RegexOptions.IgnoreCase)]
private static partial Regex StaffelUndFolge();
/// 1x02, 01x02 — die ältere Schreibweise.
[GeneratedRegex(@"(?\d{1,2})x(?\d{2,3})(?![\dx])", RegexOptions.IgnoreCase)]
private static partial Regex KreuzSchreibweise();
/// Ein Jahr in Klammern: (1999) oder [1999].
[GeneratedRegex(@"[\(\[](?(?:18|19|20)\d{2})[\)\]]")]
private static partial Regex JahrInKlammern();
[GeneratedRegex(@"\s+")]
private static partial Regex Leerraum();
/// Liest Titel und Jahr aus einem Filmdateinamen.
public static MovieName ParseMovie(string dateiname)
{
var (worte, jahrAusKlammern) = Zerlegen(dateiname);
var (titel, jahr) = TitelBis(worte, grenze: worte.Length);
return new MovieName(titel.Length > 0 ? titel : Rohname(dateiname), jahrAusKlammern ?? jahr);
}
/// Der Name der Fassung — oder null, wenn der Name keine nennt.
///
///
///
/// Eine Fassung ist kein eigener Film. Die mobile Fassung, die
/// 4K-Fassung und das Original gehören zu einem Eintrag mit drei Dateien.
/// wirft den Zusatz deshalb weg — hier wird er
/// gelesen, damit die Dateien in der Auswahl Namen tragen statt Pfade.
///
///
/// Drei Schreibweisen, in dieser Reihenfolge: die Plex-Form
/// {edition-Mobil}, ein Zusatz hinter dem letzten Bindestrich
/// (Film (2021) - Mobil.mp4), und ein Elternordner, der nur die
/// Fassung benennt (Mobil/Film (2021).mp4).
///
///
/// Findet sich nichts, bleibt es bei null — dann bildet der Aufrufer
/// den Namen aus der gemessenen Auflösung. Zu raten wäre hier schlecht:
/// „Directors Cut" ist eine Fassung, „GRP" ist eine Veröffentlichungsgruppe.
///
///
public static string? Fassung(string pfad)
{
var roh = Rohname(pfad);
var klammer = EditionInKlammern().Match(roh);
if (klammer.Success) return Saeubern(klammer.Groups["name"].Value);
var strich = roh.LastIndexOf(" - ", StringComparison.Ordinal);
if (strich > 0)
{
var hinten = Saeubern(roh[(strich + 3)..]);
if (hinten.Length > 0 && IstFassungsname(hinten)) return Vereinheitlichen(hinten);
}
var ordner = Path.GetFileName(Path.GetDirectoryName(pfad) ?? "");
if (ordner.Length > 0 && IstFassungsname(ordner)) return Vereinheitlichen(Saeubern(ordner));
return null;
}
/// Macht aus einer gemessenen Höhe einen Namen: 2160 wird „4K".
///
///
/// Der Rückfall, wenn der Dateiname nichts hergibt. Zwei Dateien ohne
/// Zusatz hiessen sonst beide gleich, und in der Auswahl stünde zweimal
/// dasselbe.
///
public static string? FassungAusHoehe(int? hoehe) => hoehe switch
{
null or <= 0 => null,
>= 2000 => "4K",
>= 1400 => "1440p",
>= 1000 => "1080p",
>= 700 => "720p",
>= 500 => "576p",
_ => "SD",
};
private static bool IstFassungsname(string wort)
{
var blank = wort.Trim();
return Auflösung().IsMatch(blank) || Fassungsnamen.ContainsKey(Schlank(blank));
}
/// Schreibweisen, die dasselbe meinen, bekommen denselben Namen.
private static string Vereinheitlichen(string wort) =>
Fassungsnamen.GetValueOrDefault(Schlank(wort), wort.Trim());
private static string Schlank(string wort) =>
wort.Trim().Replace(" ", "").Replace("'", "").Replace("-", "");
private static string Saeubern(string wort) =>
Leerraum().Replace(wort.Replace('_', ' ').Replace('.', ' '), " ")
.Trim(' ', '-', '–', ',', '[', ']', '(', ')', '{', '}');
/// Was als Fassung durchgeht — knapp gehalten, mit Absicht.
///
///
/// Eine geschlossene Liste. Nähme man jedes Wort hinter dem letzten
/// Bindestrich, hiesse eine Fassung irgendwann „YIFY" oder „GRP" — das
/// sind Veröffentlichungsgruppen, keine Fassungen. Lieber kein Name als
/// ein falscher: ohne Treffer greift .
///
private static readonly Dictionary Fassungsnamen =
new(StringComparer.OrdinalIgnoreCase)
{
["mobil"] = "Mobil", ["mobile"] = "Mobil",
["optimiert"] = "Mobil", ["optimized"] = "Mobil",
["directorscut"] = "Director's Cut", ["directorcut"] = "Director's Cut",
["extended"] = "Extended", ["extendedcut"] = "Extended",
["theatrical"] = "Kinofassung", ["kinofassung"] = "Kinofassung",
["uncut"] = "Uncut", ["remastered"] = "Remastered", ["imax"] = "IMAX",
["4k"] = "4K", ["8k"] = "8K", ["sd"] = "SD",
};
[GeneratedRegex(@"\{edition-(?[^}]+)\}", RegexOptions.IgnoreCase)]
private static partial Regex EditionInKlammern();
/// Liest Serie, Staffel, Folge und Folgentitel — oder null.
///
///
/// null heisst: hier steht keine Folgennummer. Dann ist es entweder
/// ein Film oder etwas, das niemand benennen kann — in beiden Fällen wäre
/// eine geratene Staffel 1 schlimmer als gar keine.
///
public static EpisodeName? ParseEpisode(string dateiname)
{
var roh = Rohname(dateiname);
var treffer = StaffelUndFolge().Match(roh);
if (!treffer.Success) treffer = KreuzSchreibweise().Match(roh);
if (!treffer.Success) return null;
if (!int.TryParse(treffer.Groups["staffel"].Value, out var staffel) ||
!int.TryParse(treffer.Groups["folge"].Value, out var folge)) return null;
var davor = roh[..treffer.Index];
var danach = roh[(treffer.Index + treffer.Length)..];
var (worteDavor, _) = Zerlegen(davor, istDateiname: false);
var (serie, _) = TitelBis(worteDavor, worteDavor.Length);
var (worteDanach, _) = Zerlegen(danach, istDateiname: false);
var (folgenTitel, _) = TitelBis(worteDanach, worteDanach.Length);
// **Steht im Dateinamen keine Serie, sagt der Ordner sie.**
//
// Bei „Dark/Staffel 1/S01E01.mkv" steht vor der Folgennummer nichts —
// und bis hierher wurde dann der ganze Dateiname zum Serientitel.
// Jede so benannte Datei ergab damit eine eigene Serie: aus zwanzig
// Folgen wurden zwanzig Serien namens „S01E01", „S01E02", …
//
// Plex und Jellyfin lesen den Namen seit je aus dem Ordner. Der
// Staffelordner wird dabei übersprungen, sonst hiesse die Serie
// „Staffel 1".
var ausOrdner = serie.Length > 0 ? null : SerieAusOrdner(dateiname);
return new EpisodeName(
SeriesTitle: serie.Length > 0 ? serie : ausOrdner ?? Rohname(dateiname),
Season: staffel,
Episode: folge,
EpisodeTitle: folgenTitel.Length > 0 ? folgenTitel : null);
}
/// Der Serienname aus dem Pfad — Staffelordner übersprungen.
///
///
/// Höchstens zwei Ebenen hoch. Weiter oben steht der Wurzelordner
/// der Mediathek, und „Serien" ist kein Serientitel.
///
private static string? SerieAusOrdner(string pfad)
{
var ordner = Path.GetDirectoryName(pfad);
for (var ebene = 0; ebene < 2 && !string.IsNullOrEmpty(ordner); ebene++)
{
var name = Path.GetFileName(ordner);
if (name.Length == 0) return null;
if (!IstStaffelordner(name))
{
var (worte, _) = Zerlegen(name, istDateiname: false);
var (titel, _) = TitelBis(worte, worte.Length);
return titel.Length > 0 ? titel : null;
}
ordner = Path.GetDirectoryName(ordner);
}
return null;
}
/// „Staffel 1", „Season 02", „S3" — Ordner, die keine Serie benennen.
private static bool IstStaffelordner(string name) =>
StaffelOrdnerName().IsMatch(name.Trim());
[GeneratedRegex(@"^(staffel|season|series|s)[\s._-]*\d{1,3}$|^(specials?|extras?)$",
RegexOptions.IgnoreCase)]
private static partial Regex StaffelOrdnerName();
/// Nimmt Worte, bis das erste Beiwerk oder eine Jahreszahl kommt.
private static (string Titel, int? Jahr) TitelBis(string[] worte, int grenze)
{
var genommen = new List();
int? jahr = null;
for (var i = 0; i < grenze; i++)
{
var wort = worte[i];
if (IstBeiwerk(wort)) break;
// Eine Jahreszahl beendet den Titel — aber nur, wenn schon etwas
// davorsteht. Sonst verlöre „2012" seinen ganzen Namen.
if (genommen.Count > 0 && IstPlausiblesJahr(wort, out var gelesen))
{
jahr = gelesen;
break;
}
genommen.Add(wort);
}
return (Säubern(string.Join(' ', genommen)), jahr);
}
private static bool IstBeiwerk(string wort)
{
var blank = wort.Trim('[', ']', '(', ')', '{', '}', '-', '_');
if (blank.Length == 0) return false;
// **Fassungsnamen beenden den Titel wie jedes andere Beiwerk.**
// Vorher tat das nur die Auflösung: aus
// „The Matrix (1999) - Mobil.mp4" wurde der Film „The Matrix - Mobil",
// also ein zweiter Eintrag neben „The Matrix" — und die mobile
// Fassung stand getrennt vom Original in der Mediathek.
if (Fassungsnamen.ContainsKey(Schlank(blank))) return true;
if (Beiwerk.Contains(blank) || Auflösung().IsMatch(blank)) return true;
// „x264-GRP" und „DD5.1" hängen am Beiwerk fest.
var vorStrich = blank.Split('-', '.')[0];
return vorStrich.Length > 1 && (Beiwerk.Contains(vorStrich) || Auflösung().IsMatch(vorStrich));
}
/// Vier Ziffern allein machen noch kein Jahr.
///
///
/// „Blade Runner 2049" ist der Fall, an dem sich das entscheidet.
/// Eine Obergrenze knapp über heute trennt eine Jahreszahl von einer Zahl
/// im Titel — alles, was weit in der Zukunft liegt, gehört zum Namen.
///
private static bool IstPlausiblesJahr(string wort, out int jahr)
{
jahr = 0;
var blank = wort.Trim('[', ']', '(', ')');
if (blank.Length != 4 || !int.TryParse(blank, NumberStyles.None, CultureInfo.InvariantCulture, out var zahl))
return false;
if (zahl < 1880 || zahl > DateTime.UtcNow.Year + 2) return false;
jahr = zahl;
return true;
}
/// Dateiname ohne Pfad und ohne Endung.
private static string Rohname(string dateiname) => Path.GetFileNameWithoutExtension(dateiname);
/// Zerlegt in Worte und holt ein eingeklammertes Jahr heraus.
///
///
/// Punkte werden nur dann zu Leerzeichen, wenn sonst keine da sind.
/// „The.Matrix.1999" braucht es; „S.W.A.T. (2003)" verlöre dadurch seinen
/// Namen. Dass im Namen überhaupt Leerzeichen stehen, heisst: hier hat
/// jemand von Hand benannt, und seine Punkte sind gewollt.
///
private static (string[] Worte, int? JahrInKlammern) Zerlegen(string eingabe, bool istDateiname = true)
{
var text = istDateiname ? Rohname(eingabe) : eingabe;
int? jahr = null;
var geklammert = JahrInKlammern().Match(text);
if (geklammert.Success && int.TryParse(geklammert.Groups["jahr"].Value, out var gelesen))
{
jahr = gelesen;
text = text.Remove(geklammert.Index, geklammert.Length);
}
text = text.Replace('_', ' ');
if (!text.Contains(' ')) text = text.Replace('.', ' ');
return (Leerraum().Split(text.Trim()).Where(w => w.Length > 0).ToArray(), jahr);
}
/// Räumt Trennzeichen weg, die am Rand übrig bleiben.
///
///
/// Der Punkt steht ausdrücklich nicht dabei. Aus „S.W.A.T." würde
/// sonst „S.W.A.T" — und ein Punkt am Titelende ist ohnehin selten, weil
/// Trennpunkte schon beim Zerlegen zu Leerzeichen werden.
///
private static string Säubern(string roh) =>
Leerraum().Replace(roh.Replace('_', ' '), " ").Trim(' ', '-', '–', ',', '[', ']', '(', ')');
}