using System.Globalization; using System.Text.RegularExpressions; namespace Flaro.Core.Library; public sealed record MovieName(string Title, int? Year); /// /// Ob der Serientitel vom Ordner kommt oder aus dem Dateinamen. Steht /// nur im Protokoll — aber genau diese Angabe fehlte, als aus 95 Serien 737 /// wurden: niemand konnte sagen, welcher der beiden Wege sie angelegt hatte. /// public sealed record EpisodeName( string SeriesTitle, int Season, int Episode, string? EpisodeTitle, bool AusOrdner = false); /// 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, Serie_S02E18, S2024E04 — die erste Folge zählt. /// /// /// /// Kein \b vor dem S, und das hat 3445 Dateien gekostet. Eine /// Wortgrenze sitzt zwischen einem Wortzeichen und einem Nicht-Wortzeichen /// — der Unterstrich ist aber selbst ein Wortzeichen. In /// The Good Doctor_S02E18_Trampolin.mkv steht zwischen r und /// S damit keine Grenze, und der ganze Bestand aus einem /// Mediathek-Download fiel durch. Die Rückschau verlangt stattdessen /// ausdrücklich: kein Buchstabe, keine Ziffer davor — ein Unterstrich /// schon. /// /// /// Vier Ziffern für die Staffel. Sendungen aus Mediatheken /// nummerieren nach Jahr: Die Anstalt_S2024E04. Mit drei Ziffern /// fiel jede davon durch. /// /// [GeneratedRegex(@"(?\d{1,4})[\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); } // **Drei Ebenen hoch, nicht nur eine.** Plex legt seine zweite Fassung // als `Plex Versions/Optimized for Mobile///…` ab — // der Ordner, der die Fassung benennt, steht dort also weit über der // Datei. Drei Ebenen decken das ab, ohne irgendwann die Mediathek // selbst für eine Fassung zu halten. var ordner = Path.GetDirectoryName(pfad); for (var ebene = 0; ebene < 3 && !string.IsNullOrEmpty(ordner); ebene++) { var name = Path.GetFileName(ordner); if (name.Length > 0 && IstFassungsname(name)) return Vereinheitlichen(Saeubern(name)); ordner = Path.GetDirectoryName(ordner); } return null; } /// Ordner, die nur Fassungen einsammeln und keine Serie benennen. /// /// /// Plex Versions ist kein Titel, sondern eine Schublade. Ohne diese /// Prüfung hiesse die Serie so, und darunter läge der halbe Bestand ein /// zweites Mal. /// private static bool IstFassungsordner(string name) => IstFassungsname(name) || Schlank(name).Equals("plexversions", StringComparison.OrdinalIgnoreCase); /// 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", // Wie Plex seine zweite Fassung nennt. Nicht um Plex zu bedienen, // sondern weil die Dateien nun einmal so auf der Platte liegen: // ohne diese Zeilen steht jede Folge zweimal untereinander, mit // zwei gleich aussehenden Knopfreihen und ohne Hinweis, warum. ["optimizedformobile"] = "Mobil", ["optimiertfürmobilgeräte"] = "Mobil", ["optimiertfuermobilgeraete"] = "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. /// /// /// Der Wurzelpfad der Mediathek. Ist er angegeben, bestimmt der erste /// Ordner darunter die Serie — und zwar allein. /// public static EpisodeName? ParseEpisode(string dateiname, string? wurzel = null) { var roh = Rohname(dateiname); var treffer = StaffelUndFolge().Match(roh); if (!treffer.Success) treffer = KreuzSchreibweise().Match(roh); int staffel, folge; if (treffer.Success) { if (!int.TryParse(treffer.Groups["staffel"].Value, out staffel) || !int.TryParse(treffer.Groups["folge"].Value, out folge)) return null; } else { // **„Folge 12" ist eine Folgennummer, nur auf Deutsch.** // // Mediatheken und Mitschnitte benennen so: // „Weihnachtsmann & Co. KG # Folge 12 # Strafe muss sein". // Eine Staffel steht dort nie — die kommt, wenn überhaupt, vom // Ordner darüber. Fehlt auch der, gilt Staffel 1: das ist die // Übereinkunft für eine Serie ohne Staffeln, nicht geraten. var nurFolge = NurFolgennummer().Match(roh); if (!nurFolge.Success || !int.TryParse(nurFolge.Groups["folge"].Value, out folge)) return null; staffel = StaffelAusOrdner(dateiname) ?? 1; treffer = nurFolge; } 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); // **Der Ordner entscheidet, nicht der Dateiname.** // // Was unter „Serien/Pingu/" liegt, gehört zu Pingu — ohne Rücksicht // darauf, wie die Datei heisst. Vorher entschied der Dateiname, und // das ging reihenweise schief: Dateien namens „S01E001_Aang.mkv" // tragen vor der Folgennummer nichts, also wurde der ganze Dateiname // zum Serientitel. Aus einer Serie mit zwanzig Folgen wurden zwanzig // Serien. // // Umgekehrt ebenso wichtig: „Doctor Who (2005) - S12E04.mkv" im Ordner // „Doctor Who" ergab zwei Serien, weil die Jahresangabe im Dateinamen // stand und im Ordner nicht. Der Ordner ist die verlässlichere Angabe, // weil ihn ein Mensch angelegt hat. // // So macht es Plex seit je, und aus demselben Grund. // **Mit Wurzel entscheidet der Ordner, ohne Wurzel nur im Notfall.** // // Steht die Wurzel fest, weiss man, welcher Ordner die Serie benennt — // der erste darunter, von einem Menschen angelegt. Fehlt sie, könnte // über dem Staffelordner irgendetwas stehen; dann ist der Dateiname // die bessere Angabe, und der Ordner greift nur, wenn er gar nichts // hergibt. var ausOrdner = wurzel is { Length: > 0 } ? SerieAusOrdner(dateiname, wurzel) : serie.Length > 0 ? null : SerieAusOrdner(dateiname, null); // **Der Rohname ist keine Serie.** // // Hier stand als letzter Rückfall `Rohname(dateiname)`. Das war der // teuerste Fehler des Projekts: liegt eine Datei direkt in der Wurzel // und trägt vor der Folgennummer nichts Brauchbares, wurde der // komplette Dateiname — samt Folgentitel — zum Serientitel. Aus 95 // Serien wurden 737, jede mit genau einer Folge, und jede frisch bei // jedem Lauf. // // Jetzt gilt: entweder der Ordner weiss es, oder der Teil vor der // Folgennummer weiss es — oder niemand weiss es, und dann wird nichts // erfunden. Was hier `null` ergibt, zählt im Scan als unerkannt und // erscheint unter „Nicht zugeordnet"; eine Geisterserie mit einer // Folge ist schlechter als ein ehrliches „weiss ich nicht". var serientitel = ausOrdner ?? (serie.Length > 0 ? serie : null); if (serientitel is null) return null; return new EpisodeName( SeriesTitle: serientitel, Season: staffel, Episode: folge, EpisodeTitle: folgenTitel.Length > 0 ? folgenTitel : null, AusOrdner: ausOrdner is not null); } /// Warum sich eine Datei keiner Serie zuordnen liess. /// /// /// Zwei Gründe, und sie verlangen Verschiedenes. Fehlt die /// Folgennummer, muss die Datei umbenannt werden. Fehlt der Serienname, /// genügt ein Ordner darum herum. „Nicht zugeordnet" allein sagt beides /// nicht — und bei 3559 Dateien ist der Unterschied die ganze Arbeit. /// public static string WarumNicht(string dateiname, string? wurzel = null) { var roh = Rohname(dateiname); var treffer = StaffelUndFolge().Match(roh); if (!treffer.Success) treffer = KreuzSchreibweise().Match(roh); if (!treffer.Success) { return "keine Folgennummer im Dateinamen (erwartet wird etwa S01E02 oder 1x02)."; } return wurzel is { Length: > 0 } && SerieAusOrdner(dateiname, wurzel) is null ? "liegt ohne Ordner direkt in der Mediathek, und vor der Folgennummer " + "steht kein Serienname." : "vor der Folgennummer steht kein Serienname."; } /// Der Serienname aus dem Pfad. /// /// /// /// Mit Wurzel: der erste Ordner darunter. Alles, was in /// Serien/Pingu/ liegt, gehört zu Pingu — egal wie tief und egal /// wie die Datei heisst. Das ist die Regel, die ein Mensch beim Anlegen /// der Ordner im Kopf hatte. /// /// /// Ohne Wurzel: höchstens zwei Ebenen hoch, Staffelordner /// übersprungen. Diesen Weg gehen nur die Tests, die mit relativen Pfaden /// arbeiten. /// /// private static string? SerieAusOrdner(string pfad, string? wurzel) { if (wurzel is { Length: > 0 }) { var unterhalb = UnterWurzel(pfad, wurzel); // Liegt die Datei direkt in der Wurzel, gibt es keinen Ordner, der // sie benennt — dann bleibt nur der Dateiname. if (unterhalb is null || !unterhalb.Contains('/')) return null; // **Schubladen überspringen.** Unter `Plex Versions/Optimized for // Mobile/` steht die Serie erst darunter; wer den ersten Ordner // nimmt, legt eine Serie namens „Plex Versions" an. var teile = unterhalb.Split('/'); foreach (var teil in teile[..^1]) { if (IstFassungsordner(teil) || IstStaffelordner(teil)) continue; return Saubermachen(teil); } return null; } 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)) return Saubermachen(name); ordner = Path.GetDirectoryName(ordner); } return null; } /// Der Pfad unterhalb der Wurzel, mit „/" getrennt. private static string? UnterWurzel(string pfad, string wurzel) { var p = pfad.Replace('\\', '/'); var w = wurzel.Replace('\\', '/').TrimEnd('/'); if (!p.StartsWith(w + "/", StringComparison.OrdinalIgnoreCase)) return null; return p[(w.Length + 1)..]; } private static string? Saubermachen(string ordnername) { var (worte, _) = Zerlegen(ordnername, istDateiname: false); var (titel, _) = TitelBis(worte, worte.Length); return titel.Length > 0 ? titel : null; } /// „Folge 12", „Episode 3", „Teil 7" — eine Nummer ohne Staffel. /// /// /// Mit Wortgrenze dahinter, damit „Folge 1" nicht aus „Folge 12" wird, und /// nur bis drei Ziffern: „Folge 2024" wäre eher ein Datum. /// [GeneratedRegex(@"(?\d{1,3})(?!\d)", RegexOptions.IgnoreCase)] private static partial Regex NurFolgennummer(); /// Die Staffelnummer aus einem Ordner „Season 4" über der Datei. /// /// /// Nur aus dem unmittelbaren Ordner. Steht dort keine Zahl, wird /// keine erfunden — der Aufrufer entscheidet, was dann gilt. /// private static int? StaffelAusOrdner(string pfad) { var ordner = Path.GetFileName(Path.GetDirectoryName(pfad.Replace('\\', '/')) ?? ""); var treffer = StaffelZahl().Match(ordner.Trim()); return treffer.Success && int.TryParse(treffer.Groups["nr"].Value, out var nr) ? nr : null; } [GeneratedRegex(@"^(?:staffel|season|series|s)[\s._-]*(?\d{1,3})$", RegexOptions.IgnoreCase)] private static partial Regex StaffelZahl(); /// „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(' ', '-', '–', ',', '[', ']', '(', ')'); }