Files
Flaro/server/src/Flaro.Core/Library/FileNames.cs
T
ScarriffleandClaude Opus 5 fa18c0d7fa Zwei Namen für denselben Titel brachen den ganzen Lauf ab
Der Eindeutigkeitsindex aus Punkt 3 stellte die Regel auf, dichtete aber
den einen Weg nicht ab, auf dem sie verletzt wird: der Enricher setzt die
TMDB-Kennung, und sobald zwei Einträge derselben Mediathek dieselbe
bekamen, brach EF die gesamte Speicherung ab — mit "An error occurred
while saving the entity changes", einer Meldung, die den Grund nicht nennt
und den ganzen Scan mitnimmt.

Jetzt wird die Kennung nicht blind gesetzt, sondern vorgemerkt und nach
der Schleife zusammengelegt. Zwei Tests halten den Fall fest: der Anbieter
antwortet auf jede Anfrage mit demselben Film beziehungsweise derselben
Serie, also genau die Lage, die vorher den Lauf zerriss.

Dazu der wahrscheinliche Grund für die siebenhundert Serien: der
Serientitel kam ausschliesslich aus dem Dateinamen. Heisst eine Datei nur
"S01E01.mkv", steht vor der Folgennummer nichts, und der ganze Dateiname
wurde zum Serientitel — zwanzig Folgen ergaben zwanzig Serien namens
"S01E01", "S01E02", und so weiter. Jetzt sagt der Ordner den Namen, wenn
die Datei ihn nicht nennt; der Staffelordner wird dabei übersprungen, sonst
hiesse die Serie "Staffel 1". Plex und Jellyfin lesen ihn seit je so.

Dazu die Prüfliste: Folgen werden nach Serie gebündelt, weil zwanzig neue
Folgen eine Entscheidung sind und nicht zwanzig. Mit Mehrfachauswahl —
anhaken und gemeinsam freigeben oder auf einen Zeitpunkt legen. Und der
Zeitpunkt ist ein datetime-local statt eines Textfelds mit Formvorschrift,
bei dem jeder Tippfehler erst nach dem Absenden auffiel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 13:58:33 +02:00

367 lines
15 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
/// <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, S01E02E03 — die erste Folge zählt.</summary>
[GeneratedRegex(@"\bS(?<staffel>\d{1,3})[\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);
}
var ordner = Path.GetFileName(Path.GetDirectoryName(pfad) ?? "");
if (ordner.Length > 0 && IstFassungsname(ordner)) return Vereinheitlichen(Saeubern(ordner));
return null;
}
/// <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",
["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>
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);
}
/// <summary>Der Serienname aus dem Pfad — Staffelordner übersprungen.</summary>
///
/// <remarks>
/// <b>Höchstens zwei Ebenen hoch.</b> Weiter oben steht der Wurzelordner
/// der Mediathek, und „Serien" ist kein Serientitel.
/// </remarks>
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;
}
/// <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(' ', '-', '–', ',', '[', ']', '(', ')');
}