Ein Film kommt je Mediathek genau einmal vor
Der Scan verglich den Titel aus dem Dateinamen zeichengenau. Ein Punkt Unterschied — Dr. Dolittle 2 gegen Dr Dolittle 2 —, oder eine Datei mit Jahresangabe neben einer ohne, und es entstanden zwei Einträge. Die Metadaten ordneten beide demselben TMDB-Eintrag zu, beide bekamen denselben Titel, dasselbe Jahr, dasselbe Plakat, und nichts führte sie je wieder zusammen. Drei Ebenen, weil eine nicht trägt. Titelschluessel vergleicht ohne Satzzeichen, ohne Akzente und ohne Grossschreibung; ein fehlendes Jahr passt auf jedes, zwei bekannte verschiedene trennen. Bewusst nicht aggressiver: Spider-Man und Spiderman bleiben getrennt, und die beiden Dune von 1984 und 2021 sowieso. Ein zusammengelegter Film lässt sich nicht mehr trennen, ohne zu wissen, welche Datei wohin gehörte. Ein gefilterter Eindeutigkeitsindex auf (LibraryId, TmdbId) stellt die Regel in die Datenbank statt in Code, der sie einhalten soll. Gefiltert, weil ohne Metadaten alle Einträge null tragen. Zusammenlegung räumt auf, was kein Vergleich erkennen könnte: Ich einfach unverbesserlich und Despicable Me sind derselbe Film, und das verrät allein die TMDB-Kennung. Läuft nach jedem Scan, und von Hand über einen Knopf in der Verwaltung. Der Sehfortschritt wird gefaltet, nicht umgehängt — die weitere Stelle gewinnt, gesehen gewinnt, weggelegt gewinnt nicht. Die Migration räumt zuerst auf und legt dann erst den Index an. Andersherum scheiterte sie auf jeder Datenbank mit Dubletten, und zwar beim Start im Container, wo die Meldung niemand sieht. Gemessen an einer Datenbank mit zwei Dubletten und zwei Sehständen: 3 Filme auf 2, beide Dateien erhalten, Position 1800 statt 300 behalten. Dazu Fassungen. MediaFile.VersionLabel liest Mobil, 4K oder Director's Cut aus dem Namen, sonst aus der gemessenen Bildhöhe. Die Liste ist geschlossen, damit eine Fassung nicht YIFY heisst. Und der Fassungsname beendet jetzt den Titel wie jedes andere Beiwerk — vorher wurde aus "The Matrix (1999) - Mobil.mp4" der Film "The Matrix - Mobil", also ein zweiter Eintrag neben dem Original. Genau die Dublette, um die es ging. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d01c5d43f4
commit
63c7ebb6d5
@@ -80,6 +80,105 @@ public static partial class FileNames
|
||||
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>
|
||||
@@ -144,6 +243,14 @@ public static partial class FileNames
|
||||
{
|
||||
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.
|
||||
|
||||
@@ -53,6 +53,16 @@ public sealed class MediaFile
|
||||
/// erneut aufbringen.
|
||||
/// </remarks>
|
||||
public DateTimeOffset? ProbedAt { get; set; }
|
||||
|
||||
/// <summary>Wie diese Fassung heisst — „4K", „Mobil", „Director's Cut".</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Eine Fassung ist kein eigener Film.</b> Mehrere Dateien gehören zu
|
||||
/// einem Eintrag, und ohne Namen stünden in der Auswahl mehrmals
|
||||
/// dieselben Angaben. Kommt aus dem Dateinamen, sonst aus der gemessenen
|
||||
/// Bildhöhe; bleibt leer, wenn es nur eine Fassung gibt.
|
||||
/// </remarks>
|
||||
public string? VersionLabel { get; set; }
|
||||
}
|
||||
|
||||
public sealed class Movie
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace Flaro.Core.Library;
|
||||
|
||||
/// <summary>Führt Schreibvarianten desselben Titels zusammen.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Wofür das nötig wurde.</b> Der Scan suchte einen vorhandenen Film über
|
||||
/// den Titel aus dem Dateinamen, zeichengenau verglichen. Zwei Dateien mit
|
||||
/// <c>Dr. Dolittle 2</c> und <c>Dr Dolittle 2</c> ergaben deshalb zwei
|
||||
/// Einträge — die danach beide demselben TMDB-Eintrag zugeordnet wurden und
|
||||
/// ununterscheidbar nebeneinander standen. Dasselbe bei
|
||||
/// <c>WALL·E</c> gegen <c>WALL-E</c> oder <c>Spider-Man</c> gegen
|
||||
/// <c>Spider Man</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Nur zum Vergleichen, nie zum Anzeigen.</b> Was hier herauskommt, ist
|
||||
/// eine Zeichenkette ohne Satzzeichen und ohne Grossschreibung — brauchbar
|
||||
/// als Schlüssel, unbrauchbar als Titel.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Bewusst nicht aggressiver.</b> Artikel zu streichen oder Zahlwörter
|
||||
/// umzuschreiben würfe irgendwann zwei verschiedene Filme zusammen, und das
|
||||
/// wäre schlimmer als zwei Einträge für einen: ein zusammengelegter Film
|
||||
/// lässt sich nicht mehr trennen, ohne zu wissen, welche Datei wohin gehörte.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class Titelschluessel
|
||||
{
|
||||
/// <summary>Der Vergleichsschlüssel zu einem Titel.</summary>
|
||||
public static string Von(string? titel)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(titel)) return "";
|
||||
|
||||
// Zerlegt „é" in „e" + Akzent, damit der Akzent gleich mit herausfällt.
|
||||
// Ohne das wären „Amelie" und „Amélie" zwei Filme.
|
||||
var zerlegt = titel.Normalize(NormalizationForm.FormD);
|
||||
|
||||
var bau = new StringBuilder(zerlegt.Length);
|
||||
var letzterWarLeer = true;
|
||||
|
||||
foreach (var z in zerlegt)
|
||||
{
|
||||
if (CharUnicodeInfo.GetUnicodeCategory(z) == UnicodeCategory.NonSpacingMark) continue;
|
||||
|
||||
if (char.IsLetterOrDigit(z))
|
||||
{
|
||||
bau.Append(char.ToLowerInvariant(z));
|
||||
letzterWarLeer = false;
|
||||
}
|
||||
else if (!letzterWarLeer)
|
||||
{
|
||||
// Jedes Satz- und Sonderzeichen wird zu einem Leerzeichen —
|
||||
// damit „Spider-Man" und „Spider Man" denselben Schlüssel
|
||||
// haben, „Spider-Man" und „Spiderman" aber nicht. Letzteres
|
||||
// wäre ein Raten, das irgendwann danebenläge.
|
||||
bau.Append(' ');
|
||||
letzterWarLeer = true;
|
||||
}
|
||||
}
|
||||
|
||||
return bau.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
/// <summary>Ob zwei Fundstücke derselbe Titel sind.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Ein fehlendes Jahr passt auf jedes.</b> Eine Datei heisst
|
||||
/// <c>Descendants 2 (2017).mkv</c>, die zweite nur <c>Descendants 2.mkv</c>
|
||||
/// — das ist der häufigste Grund für eine Dublette gewesen. Stehen
|
||||
/// dagegen <b>beide</b> Jahre da und sind verschieden, sind es zwei
|
||||
/// Filme: die beiden „Dune" von 1984 und 2021 dürfen nie verschmelzen.
|
||||
/// </remarks>
|
||||
public static bool Gleich(string? titelA, int? jahrA, string? titelB, int? jahrB)
|
||||
{
|
||||
var a = Von(titelA);
|
||||
if (a.Length == 0 || a != Von(titelB)) return false;
|
||||
return jahrA is null || jahrB is null || jahrA == jahrB;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user