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:
Scarriffle
2026-10-04 11:39:10 +02:00
co-authored by Claude Opus 5
parent d01c5d43f4
commit 63c7ebb6d5
16 changed files with 1743 additions and 9 deletions
@@ -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();
+1
View File
@@ -74,6 +74,7 @@ builder.Services.AddSingleton(SidecarOptions.FromEnvironment());
builder.Services.AddSingleton<ISidecarStore, SidecarWriter>();
builder.Services.AddScoped<LibraryScanner>();
builder.Services.AddScoped<Zusammenlegung>();
// Metadaten. Der Anbieter steckt hinter einer Schnittstelle, damit alles, was
// ihn benutzt, ohne Netz und ohne Schlüssel geprüft werden kann.
+15
View File
@@ -79,6 +79,21 @@ public sealed class ScanCoordinator(
var metadaten = await bereich.ServiceProvider
.GetRequiredService<MetadataEnricher>().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<Zusammenlegung>().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",
+107
View File
@@ -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;
}
}
+31
View File
@@ -113,6 +113,7 @@ public sealed class FlaroContext(DbContextOptions<FlaroContext> 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<FlaroContext> 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<FlaroContext> 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);
+41 -6
View File
@@ -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(
/// <summary>Sucht den Film — oder legt ihn an.</summary>
///
/// <remarks>
/// Der Titel **aus dem Dateinamen** und das Jahr entscheiden — nicht der
/// angezeigte Titel, der nach dem ersten Lauf von der Metadatenquelle
/// <para>
/// Der Titel <b>aus dem Dateinamen</b> 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.
/// </para>
/// <para>
/// <b>Verglichen wird über <see cref="Titelschluessel"/>, nicht Zeichen
/// für Zeichen.</b> Vorher ergaben <c>Dr. Dolittle 2</c> und
/// <c>Dr Dolittle 2</c> zwei Einträge, die anschliessend beide demselben
/// TMDB-Eintrag zugeordnet wurden und ununterscheidbar nebeneinander
/// standen. Ebenso eine Datei mit Jahresangabe neben einer ohne.
/// </para>
/// </remarks>
private Movie FilmFinden(List<Movie> 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;
}
/// <summary>Sucht die Serie — oder legt sie an.</summary>
///
/// <remarks>
/// Über denselben Schlüssel wie beim Film: <c>Game of Thrones</c> und
/// <c>Game.of.Thrones</c> sind eine Serie, nicht zwei. Ein Jahr steht am
/// Serienordner selten, deshalb gibt es hier keines zu vergleichen.
/// </remarks>
private Series SerieFinden(List<Series> 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
@@ -0,0 +1,786 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid?>("ApprovedByUserId")
.HasColumnType("TEXT");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<long?>("ConsumedAt")
.HasColumnType("INTEGER");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("DeviceName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("ExpiresAt")
.HasColumnType("INTEGER");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("DeviceName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("LastSeenAt")
.HasColumnType("INTEGER");
b.Property<long?>("RevokedAt")
.HasColumnType("INTEGER");
b.Property<string>("TokenFingerprint")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Libraries");
});
modelBuilder.Entity("Flaro.Core.Library.Credit", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Character")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<int>("Order")
.HasColumnType("INTEGER");
b.Property<Guid>("PersonId")
.HasColumnType("TEXT");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<Guid?>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<int>("EpisodeNumber")
.HasColumnType("INTEGER");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<int>("SeasonNumber")
.HasColumnType("INTEGER");
b.Property<Guid>("SeriesId")
.HasColumnType("TEXT");
b.Property<string>("StillImage")
.HasColumnType("TEXT");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("GenreId")
.HasColumnType("TEXT");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<Guid?>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long?>("BitRate")
.HasColumnType("INTEGER");
b.Property<string>("Container")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<long?>("Duration")
.HasColumnType("INTEGER");
b.Property<Guid?>("EpisodeId")
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<long>("ModifiedAt")
.HasColumnType("INTEGER");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<long?>("ProbedAt")
.HasColumnType("INTEGER");
b.Property<long>("SeenAt")
.HasColumnType("INTEGER");
b.Property<long>("SizeBytes")
.HasColumnType("INTEGER");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<int?>("Channels")
.HasColumnType("INTEGER");
b.Property<string>("Codec")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int?>("Height")
.HasColumnType("INTEGER");
b.Property<int>("Index")
.HasColumnType("INTEGER");
b.Property<bool>("IsDefault")
.HasColumnType("INTEGER");
b.Property<bool>("IsForced")
.HasColumnType("INTEGER");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("Language")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<Guid>("MediaFileId")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int?>("Width")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("MediaFileId", "Index")
.IsUnique();
b.ToTable("MediaStreams");
});
modelBuilder.Entity("Flaro.Core.Library.Movie", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<string>("BackdropImage")
.HasColumnType("TEXT");
b.Property<string>("ContentRating")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("ImdbId")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<string>("LogoImage")
.HasColumnType("TEXT");
b.Property<bool>("MatchLocked")
.HasColumnType("INTEGER");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<string>("ParsedTitle")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("ParsedYear")
.HasColumnType("INTEGER");
b.Property<string>("PosterImage")
.HasColumnType("TEXT");
b.Property<double?>("Rating")
.HasColumnType("REAL");
b.Property<int?>("RuntimeMinutes")
.HasColumnType("INTEGER");
b.Property<string>("SortTitleValue")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Tagline")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<int?>("TmdbId")
.HasColumnType("INTEGER");
b.Property<int?>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Image")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int>("TmdbId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("TmdbId")
.IsUnique();
b.ToTable("People");
});
modelBuilder.Entity("Flaro.Core.Library.Series", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<string>("BackdropImage")
.HasColumnType("TEXT");
b.Property<string>("ContentRating")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("ImdbId")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<string>("LogoImage")
.HasColumnType("TEXT");
b.Property<bool>("MatchLocked")
.HasColumnType("INTEGER");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<string>("ParsedTitle")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("ParsedYear")
.HasColumnType("INTEGER");
b.Property<string>("PosterImage")
.HasColumnType("TEXT");
b.Property<double?>("Rating")
.HasColumnType("REAL");
b.Property<int?>("RuntimeMinutes")
.HasColumnType("INTEGER");
b.Property<string>("SortTitleValue")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Tagline")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<int?>("TmdbId")
.HasColumnType("INTEGER");
b.Property<int?>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("Dismissed")
.HasColumnType("INTEGER");
b.Property<Guid?>("EpisodeId")
.HasColumnType("TEXT");
b.Property<bool>("Favourite")
.HasColumnType("INTEGER");
b.Property<bool>("InProgress")
.HasColumnType("INTEGER");
b.Property<long?>("LastPlayedAt")
.HasColumnType("INTEGER");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<int>("PlayCount")
.HasColumnType("INTEGER");
b.Property<long>("Position")
.HasColumnType("INTEGER");
b.Property<int?>("Rating")
.HasColumnType("INTEGER");
b.Property<long?>("Runtime")
.HasColumnType("INTEGER");
b.Property<long>("UpdatedAt")
.HasColumnType("INTEGER");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.Property<bool>("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<string>("Key")
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("TEXT");
b.HasKey("Key");
b.ToTable("Settings");
});
modelBuilder.Entity("Flaro.Core.Users.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<bool>("IsAdministrator")
.HasColumnType("INTEGER");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("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
}
}
}
@@ -0,0 +1,190 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Flaro.Data.Migrations
{
/// <inheritdoc />
public partial class Dubletten : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
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");
}
/// <inheritdoc />
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");
}
}
}
@@ -305,6 +305,10 @@ namespace Flaro.Data.Migrations
b.Property<long>("SizeBytes")
.HasColumnType("INTEGER");
b.Property<string>("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");
});
+278
View File
@@ -0,0 +1,278 @@
using Flaro.Core.Library;
using Flaro.Core.Playback;
using Microsoft.EntityFrameworkCore;
namespace Flaro.Data;
/// <summary>Was ein Durchgang zusammengelegt hat.</summary>
public sealed record Zusammenlegungsbericht(int Filme, int Serien)
{
public int Gesamt => Filme + Serien;
public static readonly Zusammenlegungsbericht Nichts = new(0, 0);
}
/// <summary>Legt Titel zusammen, die denselben TMDB-Eintrag meinen.</summary>
///
/// <remarks>
/// <para>
/// <b>Warum es sie überhaupt doppelt gibt.</b> 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.
/// </para>
/// <para>
/// <see cref="Titelschluessel"/> verhindert die häufigsten Fälle schon beim
/// Scan. Es bleiben die, die kein Vergleich je erkennen könnte:
/// <c>Ich einfach unverbesserlich</c> und <c>Despicable Me</c> sind derselbe
/// Film, aber kein Schlüssel der Welt bringt sie zusammen — die TMDB-Kennung
/// schon.
/// </para>
/// <para>
/// <b>Der Sehfortschritt wird gefaltet, nicht umgehängt.</b> 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 <c>UserItemState</c>. Also: die weitere Position gewinnt, „gesehen"
/// gewinnt, der grössere Zählstand gewinnt, das jüngste Datum gewinnt.
/// </para>
/// </remarks>
public sealed class Zusammenlegung(FlaroContext db)
{
/// <summary>Legt alle Dubletten einer Mediathek zusammen — oder aller, wenn keine genannt ist.</summary>
public async Task<Zusammenlegungsbericht> 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);
}
/// <summary>Hängt alles von <paramref name="verlierer"/> an <paramref name="sieger"/> und löscht ihn.</summary>
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<Guid>();
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);
}
/// <summary>Führt zwei Sehstände desselben Benutzers zu einem zusammen.</summary>
private async Task StaendeFaltenAsync(
List<UserItemState> beimSieger,
List<UserItemState> beimVerlierer,
Func<UserItemState, UserItemState> 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;
}
/// <summary>Im Zweifel das Grössere — niemand will Sehfortschritt verlieren.</summary>
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;
}
/// <summary>Hängt Genres oder Mitwirkende um, ohne dabei doppelte anzulegen.</summary>
private async Task BeiwerkUmhaengenAsync<T>(
IQueryable<T> vomVerlierer,
Task<List<Guid>> schonDaAufgabe,
Func<T, Guid> schluessel,
Func<T, T> 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<T>().Add(umgehaengt(eintrag));
schonDa.Add(schluessel(eintrag));
}
db.Set<T>().Remove(eintrag);
}
}
}
+58
View File
@@ -154,6 +154,64 @@ public class ScanTests
Is.EqualTo(120));
}
/// <summary>Schreibvarianten desselben Titels ergeben einen Eintrag.</summary>
///
/// <remarks>
/// <b>Der Fall, der die Mediathek doppelt aussehen liess.</b> 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.
/// </remarks>
[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<Page<MovieResponse>>("/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.");
}
/// <summary>Eine mobile Fassung ist kein eigener Film.</summary>
[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<Page<MovieResponse>>("/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));
}
/// <summary>Gleicher Name, anderes Jahr — zwei Filme, und das muss so bleiben.</summary>
[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<Page<MovieResponse>>("/api/v1/movies");
Assert.That(filme!.Total, Is.EqualTo(2));
}
/// <summary>4K und 1080p sind ein Film mit zwei Dateien, nicht zwei Filme.</summary>
[Test]
public async Task Zwei_Fassungen_desselben_Films_bleiben_ein_Eintrag()
@@ -0,0 +1,87 @@
using Flaro.Core.Library;
namespace Flaro.Core.Tests;
/// <summary>Welche Schreibweisen denselben Film meinen — und welche nicht.</summary>
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}\"");
/// <summary>Hier endet die Toleranz — und das ist Absicht.</summary>
///
/// <remarks>
/// 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.
/// </remarks>
[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);
/// <summary>Die beiden „Dune" dürfen nie verschmelzen.</summary>
[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.");
}
}
/// <summary>Welche Fassung eine Datei ist — 4K, Mobil, Director's Cut.</summary>
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));
/// <summary>Eine Veröffentlichungsgruppe ist keine Fassung.</summary>
///
/// <remarks>
/// 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.
/// </remarks>
[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));
/// <summary>Der Fassungszusatz darf nie im Titel landen.</summary>
[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"));
}
}
+6
View File
@@ -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<Zusammenlegungsbericht>('/system/merge-duplicates', { method: 'POST' });
/** Listet Unterordner. Ohne Pfad fängt der Server bei etwas Sinnvollem an. */
export const ordner = (pfad?: string) =>
anfrage<Ordnerstand>('/system/folders' + (pfad ? `?path=${encodeURIComponent(pfad)}` : ''));
+31 -3
View File
@@ -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}`