Zwei Befunde aus dem laufenden Betrieb. Die Liste holte genau eine Seite. Der Server gibt höchstens 200 Einträge je Zug — das ist richtig so —, nur fragte die Oberfläche nie nach der zweiten. Bei 455 Filmen endete die Mediathek mitten im Alphabet, ohne Hinweis, dass da noch etwas kommt. Jetzt wird beim Scrollen nachgeladen, und die Zahl sagt „200 von 455" statt einer Behauptung, die man beim Scrollen widerlegt. Dabei kam heraus, dass ein IntersectionObserver eine Seite braucht, die auch gezeichnet wird: im Fenster im Hintergrund löst er nicht aus, und beim Prüfen blieb die Liste genau deshalb bei 200 stehen. Daneben steht jetzt eine Prüfung am Scrollereignis, die überall greift. Der zweite Befund: jede Folge zeigte zwei gleich aussehende Knopfreihen. Es sind zwei Dateien — Plex' „Optimized for Mobile"-Kopien. Flaro kannte „optimized", aber nicht „Optimized for Mobile", und sah ohnehin nur den unmittelbaren Elternordner; Plex legt die Fassung aber als `Plex Versions/Optimized for Mobile/<Serie>/<Staffel>/…` ab. Jetzt wird drei Ebenen hoch gesucht, die Schublade `Plex Versions` benennt keine Serie mehr, und an der Folge steht „Mobil" statt einer zweiten, unerklärten Reihe. Nicht um Plex zu bedienen — die Dateien liegen nun einmal so auf der Platte. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
567 lines
24 KiB
C#
567 lines
24 KiB
C#
using System.Globalization;
|
||
using System.Text.RegularExpressions;
|
||
|
||
namespace Flaro.Core.Library;
|
||
|
||
public sealed record MovieName(string Title, int? Year);
|
||
|
||
/// <param name="AusOrdner">
|
||
/// <b>Ob der Serientitel vom Ordner kommt oder aus dem Dateinamen.</b> 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.
|
||
/// </param>
|
||
public sealed record EpisodeName(
|
||
string SeriesTitle, int Season, int Episode, string? EpisodeTitle, bool AusOrdner = false);
|
||
|
||
/// <summary>Macht aus einem Dateinamen wieder Titel, Jahr, Staffel und Folge.</summary>
|
||
///
|
||
/// <remarks>
|
||
/// <para>
|
||
/// <b>Hier scheitern Medienserver in der Praxis.</b> Was auf der Platte liegt,
|
||
/// heisst nicht „Der Fall", sondern
|
||
/// <c>Notruf.Hafenkante.S01E01.German.1080p.WEB-DL.x264-GRP.mkv</c>, 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.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>Das Verfahren: abschneiden, nicht herauslösen.</b> 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.
|
||
/// </para>
|
||
/// </remarks>
|
||
public static partial class FileNames
|
||
{
|
||
/// <summary>Merkmale, ab denen nur noch Technik kommt.</summary>
|
||
///
|
||
/// <remarks>
|
||
/// Nicht vollständig und kann es nicht sein. Es muss nur das erste treffen
|
||
/// — alles dahinter fällt ohnehin weg.
|
||
/// </remarks>
|
||
private static readonly HashSet<string> 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",
|
||
};
|
||
|
||
/// <summary>Auflösungen wie 1080p, 2160p, 720i — und 4K.</summary>
|
||
[GeneratedRegex(@"^\d{3,4}[pi]$|^[48]k$", RegexOptions.IgnoreCase)]
|
||
private static partial Regex Auflösung();
|
||
|
||
/// <summary>S01E02, s1e2, S01.E02, Serie_S02E18, S2024E04 — die erste Folge zählt.</summary>
|
||
///
|
||
/// <remarks>
|
||
/// <para>
|
||
/// <b>Kein <c>\b</c> vor dem S, und das hat 3445 Dateien gekostet.</b> Eine
|
||
/// Wortgrenze sitzt zwischen einem Wortzeichen und einem Nicht-Wortzeichen
|
||
/// — der Unterstrich ist aber selbst ein Wortzeichen. In
|
||
/// <c>The Good Doctor_S02E18_Trampolin.mkv</c> steht zwischen <c>r</c> und
|
||
/// <c>S</c> damit <i>keine</i> 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.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>Vier Ziffern für die Staffel.</b> Sendungen aus Mediatheken
|
||
/// nummerieren nach Jahr: <c>Die Anstalt_S2024E04</c>. Mit drei Ziffern
|
||
/// fiel jede davon durch.
|
||
/// </para>
|
||
/// </remarks>
|
||
[GeneratedRegex(@"(?<![A-Za-z0-9])S(?<staffel>\d{1,4})[\s._-]*E(?<folge>\d{1,4})",
|
||
RegexOptions.IgnoreCase)]
|
||
private static partial Regex StaffelUndFolge();
|
||
|
||
/// <summary>1x02, 01x02 — die ältere Schreibweise.</summary>
|
||
[GeneratedRegex(@"(?<![\dx])(?<staffel>\d{1,2})x(?<folge>\d{2,3})(?![\dx])", RegexOptions.IgnoreCase)]
|
||
private static partial Regex KreuzSchreibweise();
|
||
|
||
/// <summary>Ein Jahr in Klammern: (1999) oder [1999].</summary>
|
||
[GeneratedRegex(@"[\(\[](?<jahr>(?:18|19|20)\d{2})[\)\]]")]
|
||
private static partial Regex JahrInKlammern();
|
||
|
||
[GeneratedRegex(@"\s+")]
|
||
private static partial Regex Leerraum();
|
||
|
||
/// <summary>Liest Titel und Jahr aus einem Filmdateinamen.</summary>
|
||
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);
|
||
}
|
||
|
||
/// <summary>Der Name der Fassung — oder <c>null</c>, wenn der Name keine nennt.</summary>
|
||
///
|
||
/// <remarks>
|
||
/// <para>
|
||
/// <b>Eine Fassung ist kein eigener Film.</b> Die mobile Fassung, die
|
||
/// 4K-Fassung und das Original gehören zu einem Eintrag mit drei Dateien.
|
||
/// <see cref="ParseMovie"/> wirft den Zusatz deshalb weg — hier wird er
|
||
/// gelesen, damit die Dateien in der Auswahl Namen tragen statt Pfade.
|
||
/// </para>
|
||
/// <para>
|
||
/// Drei Schreibweisen, in dieser Reihenfolge: die Plex-Form
|
||
/// <c>{edition-Mobil}</c>, ein Zusatz hinter dem letzten Bindestrich
|
||
/// (<c>Film (2021) - Mobil.mp4</c>), und ein Elternordner, der nur die
|
||
/// Fassung benennt (<c>Mobil/Film (2021).mp4</c>).
|
||
/// </para>
|
||
/// <para>
|
||
/// Findet sich nichts, bleibt es bei <c>null</c> — 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.
|
||
/// </para>
|
||
/// </remarks>
|
||
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/<Serie>/<Staffel>/…` 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;
|
||
}
|
||
|
||
/// <summary>Ordner, die nur Fassungen einsammeln und keine Serie benennen.</summary>
|
||
///
|
||
/// <remarks>
|
||
/// <c>Plex Versions</c> ist kein Titel, sondern eine Schublade. Ohne diese
|
||
/// Prüfung hiesse die Serie so, und darunter läge der halbe Bestand ein
|
||
/// zweites Mal.
|
||
/// </remarks>
|
||
private static bool IstFassungsordner(string name) =>
|
||
IstFassungsname(name) || Schlank(name).Equals("plexversions", StringComparison.OrdinalIgnoreCase);
|
||
|
||
/// <summary>Macht aus einer gemessenen Höhe einen Namen: 2160 wird „4K".</summary>
|
||
///
|
||
/// <remarks>
|
||
/// Der Rückfall, wenn der Dateiname nichts hergibt. Zwei Dateien ohne
|
||
/// Zusatz hiessen sonst beide gleich, und in der Auswahl stünde zweimal
|
||
/// dasselbe.
|
||
/// </remarks>
|
||
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));
|
||
}
|
||
|
||
/// <summary>Schreibweisen, die dasselbe meinen, bekommen denselben Namen.</summary>
|
||
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(' ', '-', '–', ',', '[', ']', '(', ')', '{', '}');
|
||
|
||
/// <summary>Was als Fassung durchgeht — knapp gehalten, mit Absicht.</summary>
|
||
///
|
||
/// <remarks>
|
||
/// <b>Eine geschlossene Liste.</b> 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 <see cref="FassungAusHoehe"/>.
|
||
/// </remarks>
|
||
private static readonly Dictionary<string, string> 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-(?<name>[^}]+)\}", RegexOptions.IgnoreCase)]
|
||
private static partial Regex EditionInKlammern();
|
||
|
||
/// <summary>Liest Serie, Staffel, Folge und Folgentitel — oder <c>null</c>.</summary>
|
||
///
|
||
/// <remarks>
|
||
/// <c>null</c> 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.
|
||
/// </remarks>
|
||
/// <param name="wurzel">
|
||
/// Der Wurzelpfad der Mediathek. Ist er angegeben, bestimmt der erste
|
||
/// Ordner darunter die Serie — und zwar <b>allein</b>.
|
||
/// </param>
|
||
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);
|
||
}
|
||
|
||
/// <summary>Warum sich eine Datei keiner Serie zuordnen liess.</summary>
|
||
///
|
||
/// <remarks>
|
||
/// <b>Zwei Gründe, und sie verlangen Verschiedenes.</b> 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.
|
||
/// </remarks>
|
||
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.";
|
||
}
|
||
|
||
/// <summary>Der Serienname aus dem Pfad.</summary>
|
||
///
|
||
/// <remarks>
|
||
/// <para>
|
||
/// <b>Mit Wurzel: der erste Ordner darunter.</b> Alles, was in
|
||
/// <c>Serien/Pingu/</c> 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.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>Ohne Wurzel: höchstens zwei Ebenen hoch</b>, Staffelordner
|
||
/// übersprungen. Diesen Weg gehen nur die Tests, die mit relativen Pfaden
|
||
/// arbeiten.
|
||
/// </para>
|
||
/// </remarks>
|
||
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;
|
||
}
|
||
|
||
/// <summary>Der Pfad unterhalb der Wurzel, mit „/" getrennt.</summary>
|
||
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;
|
||
}
|
||
|
||
/// <summary>„Folge 12", „Episode 3", „Teil 7" — eine Nummer ohne Staffel.</summary>
|
||
///
|
||
/// <remarks>
|
||
/// Mit Wortgrenze dahinter, damit „Folge 1" nicht aus „Folge 12" wird, und
|
||
/// nur bis drei Ziffern: „Folge 2024" wäre eher ein Datum.
|
||
/// </remarks>
|
||
[GeneratedRegex(@"(?<![A-Za-z])(?:folge|episode|teil|ep)[\s._#-]*(?<folge>\d{1,3})(?!\d)",
|
||
RegexOptions.IgnoreCase)]
|
||
private static partial Regex NurFolgennummer();
|
||
|
||
/// <summary>Die Staffelnummer aus einem Ordner „Season 4" über der Datei.</summary>
|
||
///
|
||
/// <remarks>
|
||
/// <b>Nur aus dem unmittelbaren Ordner.</b> Steht dort keine Zahl, wird
|
||
/// keine erfunden — der Aufrufer entscheidet, was dann gilt.
|
||
/// </remarks>
|
||
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._-]*(?<nr>\d{1,3})$", RegexOptions.IgnoreCase)]
|
||
private static partial Regex StaffelZahl();
|
||
|
||
/// <summary>„Staffel 1", „Season 02", „S3" — Ordner, die keine Serie benennen.</summary>
|
||
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();
|
||
|
||
/// <summary>Nimmt Worte, bis das erste Beiwerk oder eine Jahreszahl kommt.</summary>
|
||
private static (string Titel, int? Jahr) TitelBis(string[] worte, int grenze)
|
||
{
|
||
var genommen = new List<string>();
|
||
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));
|
||
}
|
||
|
||
/// <summary>Vier Ziffern allein machen noch kein Jahr.</summary>
|
||
///
|
||
/// <remarks>
|
||
/// <b>„Blade Runner 2049" ist der Fall, an dem sich das entscheidet.</b>
|
||
/// Eine Obergrenze knapp über heute trennt eine Jahreszahl von einer Zahl
|
||
/// im Titel — alles, was weit in der Zukunft liegt, gehört zum Namen.
|
||
/// </remarks>
|
||
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;
|
||
}
|
||
|
||
/// <summary>Dateiname ohne Pfad und ohne Endung.</summary>
|
||
private static string Rohname(string dateiname) => Path.GetFileNameWithoutExtension(dateiname);
|
||
|
||
/// <summary>Zerlegt in Worte und holt ein eingeklammertes Jahr heraus.</summary>
|
||
///
|
||
/// <remarks>
|
||
/// <b>Punkte werden nur dann zu Leerzeichen, wenn sonst keine da sind.</b>
|
||
/// „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.
|
||
/// </remarks>
|
||
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);
|
||
}
|
||
|
||
/// <summary>Räumt Trennzeichen weg, die am Rand übrig bleiben.</summary>
|
||
///
|
||
/// <remarks>
|
||
/// <b>Der Punkt steht ausdrücklich nicht dabei.</b> 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.
|
||
/// </remarks>
|
||
private static string Säubern(string roh) =>
|
||
Leerraum().Replace(roh.Replace('_', ' '), " ").Trim(' ', '-', '–', ',', '[', ']', '(', ')');
|
||
}
|