diff --git a/server/src/Flaro.Api/Endpoints/SystemEndpoints.cs b/server/src/Flaro.Api/Endpoints/SystemEndpoints.cs index b1f077b..45c8f1a 100644 --- a/server/src/Flaro.Api/Endpoints/SystemEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/SystemEndpoints.cs @@ -89,6 +89,14 @@ public static class SystemEndpoints statusCode: StatusCodes.Status404NotFound)) .RequireAuthorization(FlaroPolicies.Administrator); + // **Dubletten von Hand zusammenlegen.** Nach einem Scan passiert das + // von selbst; hier steht es für den Fall, dass doch wieder welche + // auftauchen — etwa nachdem eine Zuordnung von Hand geändert wurde. + app.MapPost("/api/v1/system/merge-duplicates", + async (Zusammenlegung zusammenlegung, CancellationToken ct) => + Results.Ok(await zusammenlegung.AlleAsync(null, ct))) + .RequireAuthorization(FlaroPolicies.Administrator); + app.MapPut("/api/v1/system/name", async (ServerNameRequest anfrage, FlaroContext db) => { var name = anfrage.Name.Trim(); diff --git a/server/src/Flaro.Api/Program.cs b/server/src/Flaro.Api/Program.cs index ec18d85..be62d2f 100644 --- a/server/src/Flaro.Api/Program.cs +++ b/server/src/Flaro.Api/Program.cs @@ -74,6 +74,7 @@ builder.Services.AddSingleton(SidecarOptions.FromEnvironment()); builder.Services.AddSingleton(); builder.Services.AddScoped(); +builder.Services.AddScoped(); // Metadaten. Der Anbieter steckt hinter einer Schnittstelle, damit alles, was // ihn benutzt, ohne Netz und ohne Schlüssel geprüft werden kann. diff --git a/server/src/Flaro.Api/ScanCoordinator.cs b/server/src/Flaro.Api/ScanCoordinator.cs index cfda58b..d5f49cd 100644 --- a/server/src/Flaro.Api/ScanCoordinator.cs +++ b/server/src/Flaro.Api/ScanCoordinator.cs @@ -79,6 +79,21 @@ public sealed class ScanCoordinator( var metadaten = await bereich.ServiceProvider .GetRequiredService().EnrichAsync(libraryId, erneut); + // **Erst jetzt lassen sich Dubletten überhaupt erkennen.** + // Beim Scan heisst der eine Eintrag „Ich einfach unverbesserlich" + // und der andere „Despicable Me"; dass es derselbe Film ist, + // verrät allein die TMDB-Kennung, und die steht erst nach den + // Metadaten fest. + var zusammengelegt = await bereich.ServiceProvider + .GetRequiredService().AlleAsync(libraryId); + + if (zusammengelegt.Gesamt > 0) + { + log.LogInformation( + "Zusammengelegt: {Filme} Film(e), {Serien} Serie(n) der Bibliothek {Kennung}.", + zusammengelegt.Filme, zusammengelegt.Serien, libraryId); + } + _zustand[libraryId] = _zustand[libraryId] with { Status = "finished", diff --git a/server/src/Flaro.Core/Library/FileNames.cs b/server/src/Flaro.Core/Library/FileNames.cs index fb2f696..db868d7 100644 --- a/server/src/Flaro.Core/Library/FileNames.cs +++ b/server/src/Flaro.Core/Library/FileNames.cs @@ -80,6 +80,105 @@ public static partial class FileNames 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. /// /// @@ -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. diff --git a/server/src/Flaro.Core/Library/MediaEntities.cs b/server/src/Flaro.Core/Library/MediaEntities.cs index 6da5dc6..f1fa825 100644 --- a/server/src/Flaro.Core/Library/MediaEntities.cs +++ b/server/src/Flaro.Core/Library/MediaEntities.cs @@ -53,6 +53,16 @@ public sealed class MediaFile /// erneut aufbringen. /// public DateTimeOffset? ProbedAt { get; set; } + + /// Wie diese Fassung heisst — „4K", „Mobil", „Director's Cut". + /// + /// + /// Eine Fassung ist kein eigener Film. 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. + /// + public string? VersionLabel { get; set; } } public sealed class Movie diff --git a/server/src/Flaro.Core/Library/Titelschluessel.cs b/server/src/Flaro.Core/Library/Titelschluessel.cs new file mode 100644 index 0000000..7e4fe1b --- /dev/null +++ b/server/src/Flaro.Core/Library/Titelschluessel.cs @@ -0,0 +1,82 @@ +using System.Globalization; +using System.Text; + +namespace Flaro.Core.Library; + +/// Führt Schreibvarianten desselben Titels zusammen. +/// +/// +/// +/// Wofür das nötig wurde. Der Scan suchte einen vorhandenen Film über +/// den Titel aus dem Dateinamen, zeichengenau verglichen. Zwei Dateien mit +/// Dr. Dolittle 2 und Dr Dolittle 2 ergaben deshalb zwei +/// Einträge — die danach beide demselben TMDB-Eintrag zugeordnet wurden und +/// ununterscheidbar nebeneinander standen. Dasselbe bei +/// WALL·E gegen WALL-E oder Spider-Man gegen +/// Spider Man. +/// +/// +/// Nur zum Vergleichen, nie zum Anzeigen. Was hier herauskommt, ist +/// eine Zeichenkette ohne Satzzeichen und ohne Grossschreibung — brauchbar +/// als Schlüssel, unbrauchbar als Titel. +/// +/// +/// Bewusst nicht aggressiver. 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. +/// +/// +public static class Titelschluessel +{ + /// Der Vergleichsschlüssel zu einem Titel. + 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(); + } + + /// Ob zwei Fundstücke derselbe Titel sind. + /// + /// + /// Ein fehlendes Jahr passt auf jedes. Eine Datei heisst + /// Descendants 2 (2017).mkv, die zweite nur Descendants 2.mkv + /// — das ist der häufigste Grund für eine Dublette gewesen. Stehen + /// dagegen beide Jahre da und sind verschieden, sind es zwei + /// Filme: die beiden „Dune" von 1984 und 2021 dürfen nie verschmelzen. + /// + 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; + } +} diff --git a/server/src/Flaro.Data/FlaroContext.cs b/server/src/Flaro.Data/FlaroContext.cs index c24b675..16b26b8 100644 --- a/server/src/Flaro.Data/FlaroContext.cs +++ b/server/src/Flaro.Data/FlaroContext.cs @@ -113,6 +113,7 @@ public sealed class FlaroContext(DbContextOptions options) : DbCon // ein zweimal angestossener Scan jede Datei zweimal an, und die // Mediathek wächst bei jedem Lauf. e.HasIndex(x => new { x.LibraryId, x.Path }).IsUnique(); + e.Property(x => x.VersionLabel).HasMaxLength(64); e.HasIndex(x => x.MovieId); e.HasIndex(x => x.EpisodeId); e.Property(x => x.Path).HasMaxLength(1024); @@ -125,6 +126,21 @@ public sealed class FlaroContext(DbContextOptions options) : DbCon { e.HasKey(x => x.Id); e.HasIndex(x => new { x.LibraryId, x.SortTitleValue }); + + // **Ein Film kommt je Mediathek genau einmal vor.** + // + // Der Scan ordnet über den Dateinamen zu, lange bevor jemand + // weiss, welcher Film das ist — weichen zwei Dateien darin ab, + // entstehen zwei Einträge, die anschliessend beide demselben + // TMDB-Eintrag zugeordnet werden. Hier steht die Regel, die das + // unmöglich macht, statt nur Code, der sie einhalten soll. + // + // Gefiltert, weil ohne Metadaten alle Einträge `null` tragen und + // ein ungefilterter Index dann nur einen einzigen zuliesse. + e.HasIndex(x => new { x.LibraryId, x.TmdbId }) + .IsUnique() + .HasFilter("\"TmdbId\" IS NOT NULL"); + e.Property(x => x.Title).HasMaxLength(512); e.Property(x => x.SortTitleValue).HasMaxLength(512); e.Property(x => x.ImdbId).HasMaxLength(32); @@ -138,6 +154,21 @@ public sealed class FlaroContext(DbContextOptions options) : DbCon { e.HasKey(x => x.Id); e.HasIndex(x => new { x.LibraryId, x.SortTitleValue }); + + // **Eine Serie kommt je Mediathek genau einmal vor.** + // + // Der Scan ordnet über den Dateinamen zu, lange bevor jemand + // weiss, welcher Film das ist — weichen zwei Dateien darin ab, + // entstehen zwei Einträge, die anschliessend beide demselben + // TMDB-Eintrag zugeordnet werden. Hier steht die Regel, die das + // unmöglich macht, statt nur Code, der sie einhalten soll. + // + // Gefiltert, weil ohne Metadaten alle Einträge `null` tragen und + // ein ungefilterter Index dann nur einen einzigen zuliesse. + e.HasIndex(x => new { x.LibraryId, x.TmdbId }) + .IsUnique() + .HasFilter("\"TmdbId\" IS NOT NULL"); + e.Property(x => x.Title).HasMaxLength(512); e.Property(x => x.SortTitleValue).HasMaxLength(512); e.Property(x => x.ImdbId).HasMaxLength(32); diff --git a/server/src/Flaro.Data/LibraryScanner.cs b/server/src/Flaro.Data/LibraryScanner.cs index aeee126..a9f29f5 100644 --- a/server/src/Flaro.Data/LibraryScanner.cs +++ b/server/src/Flaro.Data/LibraryScanner.cs @@ -109,6 +109,7 @@ public sealed class LibraryScanner( bekannt.MovieId = filmId; bekannt.EpisodeId = folgeId; bekannt.SeenAt = laufZeit; + bekannt.VersionLabel = FileNames.Fassung(datei.Path) ?? bekannt.VersionLabel; if (vorher != (datei.SizeBytes, datei.ModifiedAt, filmId, folgeId)) geändert++; eintrag = bekannt; } @@ -123,6 +124,7 @@ public sealed class LibraryScanner( SeenAt = laufZeit, MovieId = filmId, EpisodeId = folgeId, + VersionLabel = FileNames.Fassung(datei.Path), }; db.MediaFiles.Add(eintrag); neu++; @@ -193,6 +195,12 @@ public sealed class LibraryScanner( datei.BitRate = gelesen.BitRate; datei.ProbedAt = laufZeit; + // **Der Rückfall für den Fassungsnamen.** Steht im Dateinamen nichts, + // taugt die gemessene Bildhöhe: zwei Dateien ohne Zusatz hiessen sonst + // beide gleich, und in der Auswahl stünde zweimal dasselbe. + datei.VersionLabel ??= FileNames.FassungAusHoehe( + gelesen.Streams.FirstOrDefault(s => s.Kind == MediaStreamKind.Video)?.Height); + await db.SaveChangesAsync(ct); // Die alten Spuren weg, bevor die neuen kommen: eine umkodierte Datei @@ -243,18 +251,38 @@ public sealed class LibraryScanner( /// Sucht den Film — oder legt ihn an. /// /// - /// Der Titel **aus dem Dateinamen** und das Jahr entscheiden — nicht der - /// angezeigte Titel, der nach dem ersten Lauf von der Metadatenquelle + /// + /// Der Titel aus dem Dateinamen und das Jahr entscheiden — nicht + /// der angezeigte Titel, der nach dem ersten Lauf von der Metadatenquelle /// kommt und sich dabei ändert. Nur über den Titel ohne Jahr zu gehen /// würfe die beiden „Dune" von 1984 und 2021 zusammen; über die Datei zu /// gehen machte aus 4K- und 1080p-Fassung zwei Filme. + /// + /// + /// Verglichen wird über , nicht Zeichen + /// für Zeichen. Vorher ergaben Dr. Dolittle 2 und + /// Dr Dolittle 2 zwei Einträge, die anschliessend beide demselben + /// TMDB-Eintrag zugeordnet wurden und ununterscheidbar nebeneinander + /// standen. Ebenso eine Datei mit Jahresangabe neben einer ohne. + /// /// private Movie FilmFinden(List bekannt, Guid libraryId, MovieName name) { var treffer = bekannt.FirstOrDefault(m => - string.Equals(m.ParsedTitle, name.Title, StringComparison.OrdinalIgnoreCase) - && m.ParsedYear == name.Year); - if (treffer is not null) return treffer; + Titelschluessel.Gleich(m.ParsedTitle, m.ParsedYear, name.Title, name.Year)); + if (treffer is not null) + { + // **Das Anzeigejahr nachtragen, wenn es bisher fehlte.** Trug die + // erste Datei keines im Namen und die zweite schon, stünde sonst + // ein Film ohne Jahr da, obwohl es bekannt ist. + // + // `ParsedYear` bleibt unangetastet — es ist der Vergleichsschlüssel + // und deshalb `init`. Das ist kein Mangel: ein fehlendes Jahr passt + // in `Titelschluessel.Gleich` ohnehin auf jedes, der Eintrag findet + // also weiterhin beide Schreibweisen. + treffer.Year ??= name.Year; + return treffer; + } var neu = new Movie { @@ -270,10 +298,17 @@ public sealed class LibraryScanner( return neu; } + /// Sucht die Serie — oder legt sie an. + /// + /// + /// Über denselben Schlüssel wie beim Film: Game of Thrones und + /// Game.of.Thrones sind eine Serie, nicht zwei. Ein Jahr steht am + /// Serienordner selten, deshalb gibt es hier keines zu vergleichen. + /// private Series SerieFinden(List bekannt, Guid libraryId, string titel) { var treffer = bekannt.FirstOrDefault(s => - string.Equals(s.ParsedTitle, titel, StringComparison.OrdinalIgnoreCase)); + Titelschluessel.Gleich(s.ParsedTitle, null, titel, null)); if (treffer is not null) return treffer; var neu = new Series diff --git a/server/src/Flaro.Data/Migrations/20261004093243_Dubletten.Designer.cs b/server/src/Flaro.Data/Migrations/20261004093243_Dubletten.Designer.cs new file mode 100644 index 0000000..139fcde --- /dev/null +++ b/server/src/Flaro.Data/Migrations/20261004093243_Dubletten.Designer.cs @@ -0,0 +1,786 @@ +// +using System; +using Flaro.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Flaro.Data.Migrations +{ + [DbContext(typeof(FlaroContext))] + [Migration("20261004093243_Dubletten")] + partial class Dubletten + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.12"); + + modelBuilder.Entity("Flaro.Core.Auth.AuthCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ApprovedByUserId") + .HasColumnType("TEXT"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("ConsumedAt") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("INTEGER"); + + b.Property("HandleFingerprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("HandleFingerprint") + .IsUnique(); + + b.ToTable("AuthCodes"); + }); + + modelBuilder.Entity("Flaro.Core.Auth.DeviceToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("INTEGER"); + + b.Property("RevokedAt") + .HasColumnType("INTEGER"); + + b.Property("TokenFingerprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TokenFingerprint") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("DeviceTokens"); + }); + + modelBuilder.Entity("Flaro.Core.Libraries.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Libraries"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Credit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Character") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MovieId"); + + b.HasIndex("PersonId"); + + b.HasIndex("SeriesId"); + + b.ToTable("Credits"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Episode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedAt") + .HasColumnType("INTEGER"); + + b.Property("EpisodeNumber") + .HasColumnType("INTEGER"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("SeasonNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("StillImage") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId", "SeasonNumber", "EpisodeNumber") + .IsUnique(); + + b.ToTable("Episodes"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Genres"); + }); + + modelBuilder.Entity("Flaro.Core.Library.GenreLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("GenreId") + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MovieId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("GenreId", "MovieId") + .IsUnique() + .HasFilter("\"MovieId\" IS NOT NULL"); + + b.HasIndex("GenreId", "SeriesId") + .IsUnique() + .HasFilter("\"SeriesId\" IS NOT NULL"); + + b.ToTable("GenreLinks"); + }); + + modelBuilder.Entity("Flaro.Core.Library.MediaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BitRate") + .HasColumnType("INTEGER"); + + b.Property("Container") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Duration") + .HasColumnType("INTEGER"); + + b.Property("EpisodeId") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("TEXT"); + + b.Property("ModifiedAt") + .HasColumnType("INTEGER"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("ProbedAt") + .HasColumnType("INTEGER"); + + b.Property("SeenAt") + .HasColumnType("INTEGER"); + + b.Property("SizeBytes") + .HasColumnType("INTEGER"); + + b.Property("VersionLabel") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId"); + + b.HasIndex("MovieId"); + + b.HasIndex("LibraryId", "Path") + .IsUnique(); + + b.ToTable("MediaFiles"); + }); + + modelBuilder.Entity("Flaro.Core.Library.MediaStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Channels") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("IsForced") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Language") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("MediaFileId") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("MediaFileId", "Index") + .IsUnique(); + + b.ToTable("MediaStreams"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Movie", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedAt") + .HasColumnType("INTEGER"); + + b.Property("BackdropImage") + .HasColumnType("TEXT"); + + b.Property("ContentRating") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("ImdbId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("TEXT"); + + b.Property("LogoImage") + .HasColumnType("TEXT"); + + b.Property("MatchLocked") + .HasColumnType("INTEGER"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("ParsedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ParsedYear") + .HasColumnType("INTEGER"); + + b.Property("PosterImage") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("RuntimeMinutes") + .HasColumnType("INTEGER"); + + b.Property("SortTitleValue") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TmdbId") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId", "SortTitleValue"); + + b.HasIndex("LibraryId", "TmdbId") + .IsUnique() + .HasFilter("\"TmdbId\" IS NOT NULL"); + + b.ToTable("Movies"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Image") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("TmdbId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TmdbId") + .IsUnique(); + + b.ToTable("People"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedAt") + .HasColumnType("INTEGER"); + + b.Property("BackdropImage") + .HasColumnType("TEXT"); + + b.Property("ContentRating") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("ImdbId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("TEXT"); + + b.Property("LogoImage") + .HasColumnType("TEXT"); + + b.Property("MatchLocked") + .HasColumnType("INTEGER"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("ParsedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ParsedYear") + .HasColumnType("INTEGER"); + + b.Property("PosterImage") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("RuntimeMinutes") + .HasColumnType("INTEGER"); + + b.Property("SortTitleValue") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TmdbId") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId", "SortTitleValue"); + + b.HasIndex("LibraryId", "TmdbId") + .IsUnique() + .HasFilter("\"TmdbId\" IS NOT NULL"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Dismissed") + .HasColumnType("INTEGER"); + + b.Property("EpisodeId") + .HasColumnType("TEXT"); + + b.Property("Favourite") + .HasColumnType("INTEGER"); + + b.Property("InProgress") + .HasColumnType("INTEGER"); + + b.Property("LastPlayedAt") + .HasColumnType("INTEGER"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("PlayCount") + .HasColumnType("INTEGER"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Runtime") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Watched") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId"); + + b.HasIndex("MovieId"); + + b.HasIndex("UserId", "EpisodeId") + .IsUnique() + .HasFilter("\"EpisodeId\" IS NOT NULL"); + + b.HasIndex("UserId", "MovieId") + .IsUnique() + .HasFilter("\"MovieId\" IS NOT NULL"); + + b.HasIndex("UserId", "InProgress", "UpdatedAt"); + + b.ToTable("UserItemStates"); + }); + + modelBuilder.Entity("Flaro.Core.ServerSetting", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("Settings"); + }); + + modelBuilder.Entity("Flaro.Core.Users.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("IsAdministrator") + .HasColumnType("INTEGER"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.HasKey("Id"); + + b.HasIndex("UserName") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Flaro.Core.Auth.DeviceToken", b => + { + b.HasOne("Flaro.Core.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Library.Credit", b => + { + b.HasOne("Flaro.Core.Library.Movie", null) + .WithMany() + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Library.Person", null) + .WithMany() + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Flaro.Core.Library.Series", null) + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Flaro.Core.Library.Episode", b => + { + b.HasOne("Flaro.Core.Library.Series", null) + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Library.GenreLink", b => + { + b.HasOne("Flaro.Core.Library.Genre", null) + .WithMany() + .HasForeignKey("GenreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Flaro.Core.Library.Movie", null) + .WithMany() + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Library.Series", null) + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Flaro.Core.Library.MediaFile", b => + { + b.HasOne("Flaro.Core.Libraries.Library", null) + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Library.MediaStream", b => + { + b.HasOne("Flaro.Core.Library.MediaFile", null) + .WithMany() + .HasForeignKey("MediaFileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Library.Movie", b => + { + b.HasOne("Flaro.Core.Libraries.Library", null) + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Library.Series", b => + { + b.HasOne("Flaro.Core.Libraries.Library", null) + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b => + { + b.HasOne("Flaro.Core.Library.Episode", null) + .WithMany() + .HasForeignKey("EpisodeId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Library.Movie", null) + .WithMany() + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/server/src/Flaro.Data/Migrations/20261004093243_Dubletten.cs b/server/src/Flaro.Data/Migrations/20261004093243_Dubletten.cs new file mode 100644 index 0000000..15e8288 --- /dev/null +++ b/server/src/Flaro.Data/Migrations/20261004093243_Dubletten.cs @@ -0,0 +1,190 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Flaro.Data.Migrations +{ + /// + public partial class Dubletten : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "VersionLabel", + table: "MediaFiles", + type: "TEXT", + maxLength: 64, + nullable: true); + + // **Erst aufräumen, dann die Regel aufstellen.** Andersherum + // scheitert diese Migration auf jeder Datenbank, in der schon + // Dubletten stehen — und zwar beim Start im Container, wo die + // Meldung niemand sieht und der Server einfach nicht hochkommt. + // + // In SQL und nicht über die Anwendung, weil Migrationen vor allem + // anderen laufen: zu diesem Zeitpunkt gibt es noch keine Dienste. + // + // Der älteste Eintrag gewinnt. Die Kennungen sind Guid v7 und + // damit zeitlich geordnet, `MIN` liefert also den zuerst + // angelegten — den, auf den vorhandene Verläufe schon zeigen. + migrationBuilder.Sql(""" + CREATE TEMP TABLE FilmDubletten AS + SELECT m.Id AS Verlierer, + (SELECT MIN(s.Id) FROM Movies s + WHERE s.LibraryId = m.LibraryId AND s.TmdbId = m.TmdbId) AS Sieger + FROM Movies m + WHERE m.TmdbId IS NOT NULL + AND m.Id <> (SELECT MIN(s.Id) FROM Movies s + WHERE s.LibraryId = m.LibraryId AND s.TmdbId = m.TmdbId); + """); + + // Die Dateien zuerst — sie sind das Einzige, was sich nicht + // wiederherstellen lässt. + migrationBuilder.Sql(""" + UPDATE MediaFiles + SET MovieId = (SELECT Sieger FROM FilmDubletten WHERE Verlierer = MediaFiles.MovieId) + WHERE MovieId IN (SELECT Verlierer FROM FilmDubletten); + """); + + // **Sehfortschritt: erst die konfliktfreien umhängen.** Hat + // derselbe Benutzer beide Einträge angefangen, ginge das nicht — + // die Eindeutigkeitsregel auf UserItemStates verböte die zweite + // Zeile. + migrationBuilder.Sql(""" + UPDATE UserItemStates + SET MovieId = (SELECT Sieger FROM FilmDubletten WHERE Verlierer = UserItemStates.MovieId) + WHERE MovieId IN (SELECT Verlierer FROM FilmDubletten) + AND NOT EXISTS ( + SELECT 1 FROM UserItemStates z + WHERE z.UserId = UserItemStates.UserId + AND z.MovieId = (SELECT Sieger FROM FilmDubletten + WHERE Verlierer = UserItemStates.MovieId)); + """); + + // Für die übrigen gewinnt der weitere Stand — niemand will + // Sehfortschritt verlieren, nur weil zwei Einträge verschmelzen. + migrationBuilder.Sql(""" + UPDATE UserItemStates + SET Position = MAX(Position, COALESCE(( + SELECT a.Position FROM UserItemStates a + JOIN FilmDubletten d ON a.MovieId = d.Verlierer + WHERE a.UserId = UserItemStates.UserId AND d.Sieger = UserItemStates.MovieId + LIMIT 1), 0)), + Watched = MAX(Watched, COALESCE(( + SELECT a.Watched FROM UserItemStates a + JOIN FilmDubletten d ON a.MovieId = d.Verlierer + WHERE a.UserId = UserItemStates.UserId AND d.Sieger = UserItemStates.MovieId + LIMIT 1), 0)), + PlayCount = MAX(PlayCount, COALESCE(( + SELECT a.PlayCount FROM UserItemStates a + JOIN FilmDubletten d ON a.MovieId = d.Verlierer + WHERE a.UserId = UserItemStates.UserId AND d.Sieger = UserItemStates.MovieId + LIMIT 1), 0)) + WHERE MovieId IN (SELECT Sieger FROM FilmDubletten); + """); + + migrationBuilder.Sql( + "DELETE FROM UserItemStates WHERE MovieId IN (SELECT Verlierer FROM FilmDubletten);"); + + // Genres und Mitwirkende sind abgeleitet — sie entstehen beim + // nächsten Auffrischen neu. Umhängen wäre mehr Aufwand für + // dasselbe Ergebnis. + migrationBuilder.Sql( + "DELETE FROM GenreLinks WHERE MovieId IN (SELECT Verlierer FROM FilmDubletten);"); + migrationBuilder.Sql( + "DELETE FROM Credits WHERE MovieId IN (SELECT Verlierer FROM FilmDubletten);"); + migrationBuilder.Sql( + "DELETE FROM Movies WHERE Id IN (SELECT Verlierer FROM FilmDubletten);"); + migrationBuilder.Sql("DROP TABLE FilmDubletten;"); + + // --- Dasselbe für Serien --------------------------------------- + migrationBuilder.Sql(""" + CREATE TEMP TABLE SerienDubletten AS + SELECT x.Id AS Verlierer, + (SELECT MIN(s.Id) FROM Series s + WHERE s.LibraryId = x.LibraryId AND s.TmdbId = x.TmdbId) AS Sieger + FROM Series x + WHERE x.TmdbId IS NOT NULL + AND x.Id <> (SELECT MIN(s.Id) FROM Series s + WHERE s.LibraryId = x.LibraryId AND s.TmdbId = x.TmdbId); + """); + + // Folgen, deren Platz beim Sieger frei ist, wandern mit. + migrationBuilder.Sql(""" + UPDATE Episodes + SET SeriesId = (SELECT Sieger FROM SerienDubletten WHERE Verlierer = Episodes.SeriesId) + WHERE SeriesId IN (SELECT Verlierer FROM SerienDubletten) + AND NOT EXISTS ( + SELECT 1 FROM Episodes e + WHERE e.SeriesId = (SELECT Sieger FROM SerienDubletten + WHERE Verlierer = Episodes.SeriesId) + AND e.SeasonNumber = Episodes.SeasonNumber + AND e.EpisodeNumber = Episodes.EpisodeNumber); + """); + + // Die übrigen sind doppelt belegt: die Datei wandert zur Folge des + // Siegers, die doppelte Folge fällt weg. Sonst stünde S01E01 + // zweimal in der Staffelliste. + migrationBuilder.Sql(""" + UPDATE MediaFiles + SET EpisodeId = ( + SELECT e.Id FROM Episodes e + JOIN Episodes alt ON alt.Id = MediaFiles.EpisodeId + JOIN SerienDubletten d ON d.Verlierer = alt.SeriesId + WHERE e.SeriesId = d.Sieger + AND e.SeasonNumber = alt.SeasonNumber + AND e.EpisodeNumber = alt.EpisodeNumber) + WHERE EpisodeId IN ( + SELECT alt.Id FROM Episodes alt + JOIN SerienDubletten d ON d.Verlierer = alt.SeriesId); + """); + + migrationBuilder.Sql(""" + DELETE FROM UserItemStates WHERE EpisodeId IN ( + SELECT alt.Id FROM Episodes alt + JOIN SerienDubletten d ON d.Verlierer = alt.SeriesId); + """); + migrationBuilder.Sql(""" + DELETE FROM Episodes WHERE SeriesId IN (SELECT Verlierer FROM SerienDubletten); + """); + migrationBuilder.Sql( + "DELETE FROM GenreLinks WHERE SeriesId IN (SELECT Verlierer FROM SerienDubletten);"); + migrationBuilder.Sql( + "DELETE FROM Credits WHERE SeriesId IN (SELECT Verlierer FROM SerienDubletten);"); + migrationBuilder.Sql( + "DELETE FROM Series WHERE Id IN (SELECT Verlierer FROM SerienDubletten);"); + migrationBuilder.Sql("DROP TABLE SerienDubletten;"); + + migrationBuilder.CreateIndex( + name: "IX_Series_LibraryId_TmdbId", + table: "Series", + columns: new[] { "LibraryId", "TmdbId" }, + unique: true, + filter: "\"TmdbId\" IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_Movies_LibraryId_TmdbId", + table: "Movies", + columns: new[] { "LibraryId", "TmdbId" }, + unique: true, + filter: "\"TmdbId\" IS NOT NULL"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Series_LibraryId_TmdbId", + table: "Series"); + + migrationBuilder.DropIndex( + name: "IX_Movies_LibraryId_TmdbId", + table: "Movies"); + + migrationBuilder.DropColumn( + name: "VersionLabel", + table: "MediaFiles"); + } + } +} diff --git a/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs b/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs index 34e8ecc..c284c2d 100644 --- a/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs +++ b/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs @@ -305,6 +305,10 @@ namespace Flaro.Data.Migrations b.Property("SizeBytes") .HasColumnType("INTEGER"); + b.Property("VersionLabel") + .HasMaxLength(64) + .HasColumnType("TEXT"); + b.HasKey("Id"); b.HasIndex("EpisodeId"); @@ -441,6 +445,10 @@ namespace Flaro.Data.Migrations b.HasIndex("LibraryId", "SortTitleValue"); + b.HasIndex("LibraryId", "TmdbId") + .IsUnique() + .HasFilter("\"TmdbId\" IS NOT NULL"); + b.ToTable("Movies"); }); @@ -541,6 +549,10 @@ namespace Flaro.Data.Migrations b.HasIndex("LibraryId", "SortTitleValue"); + b.HasIndex("LibraryId", "TmdbId") + .IsUnique() + .HasFilter("\"TmdbId\" IS NOT NULL"); + b.ToTable("Series"); }); diff --git a/server/src/Flaro.Data/Zusammenlegung.cs b/server/src/Flaro.Data/Zusammenlegung.cs new file mode 100644 index 0000000..9b4b9c2 --- /dev/null +++ b/server/src/Flaro.Data/Zusammenlegung.cs @@ -0,0 +1,278 @@ +using Flaro.Core.Library; +using Flaro.Core.Playback; +using Microsoft.EntityFrameworkCore; + +namespace Flaro.Data; + +/// Was ein Durchgang zusammengelegt hat. +public sealed record Zusammenlegungsbericht(int Filme, int Serien) +{ + public int Gesamt => Filme + Serien; + public static readonly Zusammenlegungsbericht Nichts = new(0, 0); +} + +/// Legt Titel zusammen, die denselben TMDB-Eintrag meinen. +/// +/// +/// +/// Warum es sie überhaupt doppelt gibt. Der Scan ordnet eine Datei +/// über den Titel im Dateinamen zu — zu einem Zeitpunkt, an dem noch +/// niemand weiss, welcher Film das ist. Weichen zwei Dateien darin ab, +/// entstehen zwei Einträge. Erst die Metadaten verraten, dass beide denselben +/// Film meinen, und dann ist es für das Auseinanderhalten zu spät. +/// +/// +/// verhindert die häufigsten Fälle schon beim +/// Scan. Es bleiben die, die kein Vergleich je erkennen könnte: +/// Ich einfach unverbesserlich und Despicable Me sind derselbe +/// Film, aber kein Schlüssel der Welt bringt sie zusammen — die TMDB-Kennung +/// schon. +/// +/// +/// Der Sehfortschritt wird gefaltet, nicht umgehängt. Hat derselbe +/// Benutzer beide Einträge angefangen, gäbe es nach dem blossen Umhängen zwei +/// Zeilen für denselben Film — und genau das verbietet die Eindeutigkeitsregel +/// auf UserItemState. Also: die weitere Position gewinnt, „gesehen" +/// gewinnt, der grössere Zählstand gewinnt, das jüngste Datum gewinnt. +/// +/// +public sealed class Zusammenlegung(FlaroContext db) +{ + /// Legt alle Dubletten einer Mediathek zusammen — oder aller, wenn keine genannt ist. + public async Task AlleAsync(Guid? libraryId = null, CancellationToken ct = default) + { + var filme = await db.Movies + .Where(m => m.TmdbId != null && (libraryId == null || m.LibraryId == libraryId)) + .Select(m => new { m.Id, m.LibraryId, m.TmdbId }) + .ToListAsync(ct); + + var filmPaare = 0; + foreach (var gruppe in filme.GroupBy(m => new { m.LibraryId, m.TmdbId }).Where(g => g.Count() > 1)) + { + // **Der älteste bleibt.** Guid v7 ist zeitlich geordnet, der + // kleinste ist also der zuerst angelegte. Damit überlebt der + // Eintrag, auf den etwaige Lesezeichen und Verläufe schon zeigen. + var ids = gruppe.Select(g => g.Id).OrderBy(x => x).ToList(); + foreach (var verlierer in ids.Skip(1)) + { + await FilmVerschmelzenAsync(ids[0], verlierer, ct); + filmPaare++; + } + } + + var serien = await db.Series + .Where(s => s.TmdbId != null && (libraryId == null || s.LibraryId == libraryId)) + .Select(s => new { s.Id, s.LibraryId, s.TmdbId }) + .ToListAsync(ct); + + var serienPaare = 0; + foreach (var gruppe in serien.GroupBy(s => new { s.LibraryId, s.TmdbId }).Where(g => g.Count() > 1)) + { + var ids = gruppe.Select(g => g.Id).OrderBy(x => x).ToList(); + foreach (var verlierer in ids.Skip(1)) + { + await SerieVerschmelzenAsync(ids[0], verlierer, ct); + serienPaare++; + } + } + + if (filmPaare + serienPaare > 0) await db.SaveChangesAsync(ct); + return new Zusammenlegungsbericht(filmPaare, serienPaare); + } + + /// Hängt alles von an und löscht ihn. + public async Task FilmVerschmelzenAsync(Guid sieger, Guid verlierer, CancellationToken ct = default) + { + if (sieger == verlierer) return; + + await db.MediaFiles.Where(f => f.MovieId == verlierer) + .ExecuteUpdateAsync(s => s.SetProperty(f => f.MovieId, sieger), ct); + + await StaendeFaltenAsync( + await db.UserItemStates.Where(z => z.MovieId == sieger).ToListAsync(ct), + await db.UserItemStates.Where(z => z.MovieId == verlierer).ToListAsync(ct), + neu => new UserItemState { UserId = neu.UserId, MovieId = sieger }, + ct); + + await BeiwerkUmhaengenAsync( + db.GenreLinks.Where(g => g.MovieId == verlierer), + db.GenreLinks.Where(g => g.MovieId == sieger).Select(g => g.GenreId).ToListAsync(ct), + eintrag => eintrag.GenreId, + neu => new GenreLink { GenreId = neu.GenreId, MovieId = sieger }, + ct); + + await BeiwerkUmhaengenAsync( + db.Credits.Where(c => c.MovieId == verlierer), + db.Credits.Where(c => c.MovieId == sieger).Select(c => c.PersonId).ToListAsync(ct), + eintrag => eintrag.PersonId, + neu => new Credit + { + PersonId = neu.PersonId, MovieId = sieger, + Role = neu.Role, Character = neu.Character, + }, + ct); + + await db.Movies.Where(m => m.Id == verlierer).ExecuteDeleteAsync(ct); + } + + public async Task SerieVerschmelzenAsync(Guid sieger, Guid verlierer, CancellationToken ct = default) + { + if (sieger == verlierer) return; + + // **Folgen werden umgehängt, nicht gelöscht** — aber nur die, deren + // Platz beim Sieger frei ist. Haben beide Serien dieselbe Folge, hängt + // die Datei um und die doppelte Folge fällt weg; sonst stünde S01E01 + // zweimal in der Staffelliste. + var vorhanden = await db.Episodes.Where(f => f.SeriesId == sieger) + .Select(f => new { f.Id, f.SeasonNumber, f.EpisodeNumber }).ToListAsync(ct); + var umzuhaengen = await db.Episodes.AsNoTracking() + .Where(f => f.SeriesId == verlierer) + .Select(f => new { f.Id, f.SeasonNumber, f.EpisodeNumber }) + .ToListAsync(ct); + + var freiePlaetze = new List(); + + foreach (var folge in umzuhaengen) + { + var doppelt = vorhanden.FirstOrDefault( + v => v.SeasonNumber == folge.SeasonNumber && v.EpisodeNumber == folge.EpisodeNumber); + + if (doppelt is null) + { + freiePlaetze.Add(folge.Id); + continue; + } + + await db.MediaFiles.Where(d => d.EpisodeId == folge.Id) + .ExecuteUpdateAsync(s => s.SetProperty(d => d.EpisodeId, doppelt.Id), ct); + + await StaendeFaltenAsync( + await db.UserItemStates.Where(z => z.EpisodeId == doppelt.Id).ToListAsync(ct), + await db.UserItemStates.Where(z => z.EpisodeId == folge.Id).ToListAsync(ct), + neu => new UserItemState { UserId = neu.UserId, EpisodeId = doppelt.Id }, + ct); + + await db.Episodes.Where(f => f.Id == folge.Id).ExecuteDeleteAsync(ct); + } + + // **Über `ExecuteUpdate`, nicht über den Änderungsverfolger.** + // `Episode.SeriesId` ist `init` — die Zugehörigkeit einer Folge soll + // sich im laufenden Betrieb nicht ändern lassen. Hier ist sie es + // trotzdem, aber als einmalige Reparatur und nicht als Weg, den + // irgendein Endpunkt beschreiten könnte. + if (freiePlaetze.Count > 0) + { + await db.Episodes.Where(f => freiePlaetze.Contains(f.Id)) + .ExecuteUpdateAsync(s => s.SetProperty(f => f.SeriesId, sieger), ct); + } + + await BeiwerkUmhaengenAsync( + db.GenreLinks.Where(g => g.SeriesId == verlierer), + db.GenreLinks.Where(g => g.SeriesId == sieger).Select(g => g.GenreId).ToListAsync(ct), + eintrag => eintrag.GenreId, + neu => new GenreLink { GenreId = neu.GenreId, SeriesId = sieger }, + ct); + + await BeiwerkUmhaengenAsync( + db.Credits.Where(c => c.SeriesId == verlierer), + db.Credits.Where(c => c.SeriesId == sieger).Select(c => c.PersonId).ToListAsync(ct), + eintrag => eintrag.PersonId, + neu => new Credit + { + PersonId = neu.PersonId, SeriesId = sieger, + Role = neu.Role, Character = neu.Character, + }, + ct); + + await db.SaveChangesAsync(ct); + await db.Series.Where(s => s.Id == verlierer).ExecuteDeleteAsync(ct); + } + + /// Führt zwei Sehstände desselben Benutzers zu einem zusammen. + private async Task StaendeFaltenAsync( + List beimSieger, + List beimVerlierer, + Func umgehaengt, + CancellationToken ct) + { + foreach (var alt in beimVerlierer) + { + var da = beimSieger.FirstOrDefault(z => z.UserId == alt.UserId); + if (da is null) + { + // Niemand steht dem im Weg — einen neuen Stand anlegen und den + // alten übernehmen. Umhängen ginge auch, aber `MovieId` ist + // `init`, und das soll es bleiben. + var frisch = umgehaengt(alt); + Uebernehmen(frisch, alt); + db.UserItemStates.Add(frisch); + beimSieger.Add(frisch); + } + else + { + Falten(da, alt); + } + + db.UserItemStates.Remove(alt); + } + + await Task.CompletedTask; + } + + private static void Uebernehmen(UserItemState ziel, UserItemState quelle) + { + ziel.Position = quelle.Position; + ziel.Runtime = quelle.Runtime; + ziel.Watched = quelle.Watched; + ziel.PlayCount = quelle.PlayCount; + ziel.LastPlayedAt = quelle.LastPlayedAt; + ziel.Dismissed = quelle.Dismissed; + ziel.Favourite = quelle.Favourite; + ziel.Rating = quelle.Rating; + ziel.UpdatedAt = quelle.UpdatedAt; + } + + /// Im Zweifel das Grössere — niemand will Sehfortschritt verlieren. + private static void Falten(UserItemState ziel, UserItemState anderer) + { + if (anderer.Position > ziel.Position) + { + ziel.Position = anderer.Position; + ziel.Runtime ??= anderer.Runtime; + } + + ziel.Watched |= anderer.Watched; + ziel.Favourite |= anderer.Favourite; + // Weggelegt gewinnt **nicht**: wer einen der beiden Einträge noch auf + // „Weiterschauen" hat, will ihn dort behalten. + ziel.Dismissed &= anderer.Dismissed; + ziel.PlayCount = Math.Max(ziel.PlayCount, anderer.PlayCount); + ziel.Rating ??= anderer.Rating; + + if (anderer.LastPlayedAt > ziel.LastPlayedAt) ziel.LastPlayedAt = anderer.LastPlayedAt; + if (anderer.UpdatedAt > ziel.UpdatedAt) ziel.UpdatedAt = anderer.UpdatedAt; + } + + /// Hängt Genres oder Mitwirkende um, ohne dabei doppelte anzulegen. + private async Task BeiwerkUmhaengenAsync( + IQueryable vomVerlierer, + Task> schonDaAufgabe, + Func schluessel, + Func umgehaengt, + CancellationToken ct) + where T : class + { + var schonDa = await schonDaAufgabe; + var alte = await vomVerlierer.ToListAsync(ct); + + foreach (var eintrag in alte) + { + if (!schonDa.Contains(schluessel(eintrag))) + { + db.Set().Add(umgehaengt(eintrag)); + schonDa.Add(schluessel(eintrag)); + } + db.Set().Remove(eintrag); + } + } +} diff --git a/server/tests/Flaro.Api.Tests/ScanTests.cs b/server/tests/Flaro.Api.Tests/ScanTests.cs index 94699bc..2ec6b2b 100644 --- a/server/tests/Flaro.Api.Tests/ScanTests.cs +++ b/server/tests/Flaro.Api.Tests/ScanTests.cs @@ -154,6 +154,64 @@ public class ScanTests Is.EqualTo(120)); } + /// Schreibvarianten desselben Titels ergeben einen Eintrag. + /// + /// + /// Der Fall, der die Mediathek doppelt aussehen liess. Der Scan + /// verglich den Titel aus dem Dateinamen zeichengenau — ein Punkt + /// Unterschied, und es wurden zwei Filme, die anschliessend beide + /// denselben TMDB-Eintrag bekamen und ununterscheidbar nebeneinander + /// standen. + /// + [Test] + public async Task Schreibvarianten_ergeben_einen_Film() + { + Datei("Dr. Dolittle 2 (2001).mkv"); + Datei("Dr Dolittle 2.mkv"); + + using var server = new FlaroServer(); + var (client, bibliothek) = await MitBibliothek(server, "Movies"); + await Scannen(client, bibliothek); + + var filme = await client.GetFromJsonAsync>("/api/v1/movies"); + Assert.That(filme!.Total, Is.EqualTo(1)); + Assert.That(filme.Items[0].Files, Has.Count.EqualTo(2)); + Assert.That(filme.Items[0].Year, Is.EqualTo(2001), + "Das Jahr der einen Datei gilt auch, wenn die andere keines trägt."); + } + + /// Eine mobile Fassung ist kein eigener Film. + [Test] + public async Task Eine_optimierte_Fassung_bleibt_beim_Film() + { + Datei("The Matrix (1999).mkv"); + Datei("The Matrix (1999) - Mobil.mp4"); + + using var server = new FlaroServer(); + var (client, bibliothek) = await MitBibliothek(server, "Movies"); + await Scannen(client, bibliothek); + + var filme = await client.GetFromJsonAsync>("/api/v1/movies"); + Assert.That(filme!.Total, Is.EqualTo(1), "Sonst stünde ein zweiter Film ‚The Matrix - Mobil‘ daneben."); + Assert.That(filme.Items[0].Title, Is.EqualTo("The Matrix")); + Assert.That(filme.Items[0].Files, Has.Count.EqualTo(2)); + } + + /// Gleicher Name, anderes Jahr — zwei Filme, und das muss so bleiben. + [Test] + public async Task Zwei_Verfilmungen_bleiben_zwei_Filme() + { + Datei("Dune (1984).mkv"); + Datei("Dune (2021).mkv"); + + using var server = new FlaroServer(); + var (client, bibliothek) = await MitBibliothek(server, "Movies"); + await Scannen(client, bibliothek); + + var filme = await client.GetFromJsonAsync>("/api/v1/movies"); + Assert.That(filme!.Total, Is.EqualTo(2)); + } + /// 4K und 1080p sind ein Film mit zwei Dateien, nicht zwei Filme. [Test] public async Task Zwei_Fassungen_desselben_Films_bleiben_ein_Eintrag() diff --git a/server/tests/Flaro.Core.Tests/TitelschluesselTests.cs b/server/tests/Flaro.Core.Tests/TitelschluesselTests.cs new file mode 100644 index 0000000..c139380 --- /dev/null +++ b/server/tests/Flaro.Core.Tests/TitelschluesselTests.cs @@ -0,0 +1,87 @@ +using Flaro.Core.Library; + +namespace Flaro.Core.Tests; + +/// Welche Schreibweisen denselben Film meinen — und welche nicht. +public class TitelschluesselTests +{ + [TestCase("Dr. Dolittle 2", "Dr Dolittle 2")] + [TestCase("Spider-Man", "Spider Man")] + [TestCase("WALL·E", "WALL-E")] + [TestCase("Amélie", "Amelie")] + [TestCase("The Matrix", "the matrix")] + [TestCase("Ich einfach unverbesserlich!", "Ich einfach unverbesserlich")] + public void Gleiche_Titel_trotz_anderer_Schreibweise(string a, string b) => + Assert.That(Titelschluessel.Von(a), Is.EqualTo(Titelschluessel.Von(b)), $"„{a}" + $"\" und „{b}\""); + + /// Hier endet die Toleranz — und das ist Absicht. + /// + /// + /// Ein zusammengelegter Film lässt sich nicht mehr trennen, ohne zu + /// wissen, welche Datei wohin gehörte. Zwei Einträge für einen Film sind + /// ärgerlich; ein Eintrag für zwei Filme ist Datenverlust. + /// + [TestCase("Spider-Man", "Spiderman")] + [TestCase("Der Pate", "Der Pate 2")] + [TestCase("Dune", "Dune Part Two")] + public void Verschiedene_Titel_bleiben_verschieden(string a, string b) => + Assert.That(Titelschluessel.Von(a), Is.Not.EqualTo(Titelschluessel.Von(b))); + + [Test] + public void Ein_fehlendes_Jahr_passt_auf_jedes() => + Assert.That(Titelschluessel.Gleich("Descendants 2", null, "Descendants 2", 2017), Is.True); + + /// Die beiden „Dune" dürfen nie verschmelzen. + [Test] + public void Zwei_bekannte_Jahre_trennen() => + Assert.That(Titelschluessel.Gleich("Dune", 1984, "Dune", 2021), Is.False); + + [Test] + public void Leeres_passt_auf_nichts() + { + Assert.That(Titelschluessel.Von(""), Is.Empty); + Assert.That(Titelschluessel.Gleich("", null, "", null), Is.False, + "Zwei namenlose Einträge sind nicht derselbe Film."); + } +} + +/// Welche Fassung eine Datei ist — 4K, Mobil, Director's Cut. +public class FassungTests +{ + [TestCase("The Matrix (1999) - Mobil.mp4", "Mobil")] + [TestCase("The Matrix (1999) - optimiert.mp4", "Mobil")] + [TestCase("The Matrix (1999) - 2160p.mkv", "2160p")] + [TestCase("The Matrix (1999) {edition-Director's Cut}.mkv", "Director's Cut")] + [TestCase("Mobil/The Matrix (1999).mp4", "Mobil")] + [TestCase("The Matrix (1999) - Extended.mkv", "Extended")] + public void Erkennt_die_Fassung(string pfad, string erwartet) => + Assert.That(FileNames.Fassung(pfad), Is.EqualTo(erwartet)); + + /// Eine Veröffentlichungsgruppe ist keine Fassung. + /// + /// + /// Nähme man jedes Wort hinter dem letzten Bindestrich, hiesse eine + /// Fassung „YIFY" oder „GRP". Lieber kein Name als ein falscher — dann + /// greift der Rückfall über die Bildhöhe. + /// + [TestCase("The Matrix (1999) - YIFY.mkv")] + [TestCase("The Matrix (1999).mkv")] + [TestCase("Filme/The Matrix (1999).mkv")] + public void Raet_nicht(string pfad) => Assert.That(FileNames.Fassung(pfad), Is.Null); + + [TestCase(2160, "4K")] + [TestCase(1080, "1080p")] + [TestCase(720, "720p")] + [TestCase(480, "SD")] + [TestCase(null, null)] + public void Bildet_die_Fassung_aus_der_Hoehe(int? hoehe, string? erwartet) => + Assert.That(FileNames.FassungAusHoehe(hoehe), Is.EqualTo(erwartet)); + + /// Der Fassungszusatz darf nie im Titel landen. + [Test] + public void Die_Fassung_bleibt_aus_dem_Titel_heraus() + { + Assert.That(FileNames.ParseMovie("The Matrix (1999) - Mobil.mp4").Title, Is.EqualTo("The Matrix")); + Assert.That(FileNames.ParseMovie("The Matrix (1999) - 2160p.mkv").Title, Is.EqualTo("The Matrix")); + } +} diff --git a/web/app/src/api.ts b/web/app/src/api.ts index 6be2196..7318120 100644 --- a/web/app/src/api.ts +++ b/web/app/src/api.ts @@ -428,6 +428,12 @@ export type Ordnerstand = { note: string | null; }; +export type Zusammenlegungsbericht = { filme: number; serien: number; gesamt: number }; + +/** Legt Titel zusammen, die denselben TMDB-Eintrag meinen. */ +export const doppelteZusammenlegen = () => + anfrage('/system/merge-duplicates', { method: 'POST' }); + /** Listet Unterordner. Ohne Pfad fängt der Server bei etwas Sinnvollem an. */ export const ordner = (pfad?: string) => anfrage('/system/folders' + (pfad ? `?path=${encodeURIComponent(pfad)}` : '')); diff --git a/web/app/src/views/admin.ts b/web/app/src/views/admin.ts index 423cdcb..786eb74 100644 --- a/web/app/src/views/admin.ts +++ b/web/app/src/views/admin.ts @@ -1,6 +1,6 @@ import { bibliotheken, bibliothekAnlegen, bibliothekUmbenennen, bibliothekLoeschen, - scanStarten, scanstand, metadatenAuffrischen, + scanStarten, scanstand, metadatenAuffrischen, doppelteZusammenlegen, benutzer, benutzerAnlegen, metadatenEinstellung, metadatenSetzen, servernameSetzen, systemauskunft, laufendeWiedergaben, serverzustand, bildAdresse, @@ -21,7 +21,7 @@ import { icon } from '../icons'; * Seitenleiste steht auf ihr und muss dann neu gezeichnet werden. */ export function verwaltung(geaendert: () => void = () => {}): HTMLElement { const wurzel = document.createElement('div'); - wurzel.append(dashboard(), bibliotheksbereich(geaendert), benutzerbereich(), serverbereich()); + wurzel.append(dashboard(), bibliotheksbereich(geaendert), benutzerbereich(), serverbereich(geaendert)); return wurzel; } @@ -348,7 +348,7 @@ function benutzerbereich(): HTMLElement { /* ---------- Server ---------- */ -function serverbereich(): HTMLElement { +function serverbereich(geaendert: () => void): HTMLElement { const bereich = document.createElement('section'); bereich.appendChild(abschnitt('Server')); @@ -371,6 +371,34 @@ function serverbereich(): HTMLElement { tun: async wert => { await servernameSetzen(wert('name')); location.reload(); }, }))); + // **Wartung, nicht Alltag.** Nach jedem Scan passiert das von selbst; + // dieser Knopf ist für den Fall, dass doch wieder Dubletten auftauchen — + // etwa nachdem eine Zuordnung von Hand geändert wurde. + const wartung = zeile( + 'Doppelte Einträge', 'Zusammenlegen, was denselben Film meint', + 'Zwei Dateien mit leicht verschiedenen Namen konnten früher zwei ' + + 'Einträge ergeben. Der Sehfortschritt bleibt dabei erhalten — es ' + + 'gewinnt die weitere Stelle.'); + const legen = knopf('Zusammenlegen', 'btn'); + legen.onclick = async () => { + legen.disabled = true; + const vorher = legen.textContent; + legen.textContent = 'Läuft…'; + try { + const bericht = await doppelteZusammenlegen(); + legen.textContent = bericht.gesamt === 0 + ? 'Nichts zu tun' + : `${bericht.gesamt} zusammengelegt`; + if (bericht.gesamt > 0) geaendert(); + } catch (fehler) { + wartung.appendChild(fehlerzeile((fehler as Error).message)); + legen.textContent = vorher; + legen.disabled = false; + } + }; + wartung.querySelector('.knopfreihe')?.appendChild(legen); + liste.appendChild(wartung); + liste.appendChild(zeile( 'Metadaten', stand.usingProjectKey ? `Mitgelieferter TMDB-Zugang · ${stand.language}`