Ein Film kommt je Mediathek genau einmal vor
Der Scan verglich den Titel aus dem Dateinamen zeichengenau. Ein Punkt Unterschied — Dr. Dolittle 2 gegen Dr Dolittle 2 —, oder eine Datei mit Jahresangabe neben einer ohne, und es entstanden zwei Einträge. Die Metadaten ordneten beide demselben TMDB-Eintrag zu, beide bekamen denselben Titel, dasselbe Jahr, dasselbe Plakat, und nichts führte sie je wieder zusammen. Drei Ebenen, weil eine nicht trägt. Titelschluessel vergleicht ohne Satzzeichen, ohne Akzente und ohne Grossschreibung; ein fehlendes Jahr passt auf jedes, zwei bekannte verschiedene trennen. Bewusst nicht aggressiver: Spider-Man und Spiderman bleiben getrennt, und die beiden Dune von 1984 und 2021 sowieso. Ein zusammengelegter Film lässt sich nicht mehr trennen, ohne zu wissen, welche Datei wohin gehörte. Ein gefilterter Eindeutigkeitsindex auf (LibraryId, TmdbId) stellt die Regel in die Datenbank statt in Code, der sie einhalten soll. Gefiltert, weil ohne Metadaten alle Einträge null tragen. Zusammenlegung räumt auf, was kein Vergleich erkennen könnte: Ich einfach unverbesserlich und Despicable Me sind derselbe Film, und das verrät allein die TMDB-Kennung. Läuft nach jedem Scan, und von Hand über einen Knopf in der Verwaltung. Der Sehfortschritt wird gefaltet, nicht umgehängt — die weitere Stelle gewinnt, gesehen gewinnt, weggelegt gewinnt nicht. Die Migration räumt zuerst auf und legt dann erst den Index an. Andersherum scheiterte sie auf jeder Datenbank mit Dubletten, und zwar beim Start im Container, wo die Meldung niemand sieht. Gemessen an einer Datenbank mit zwei Dubletten und zwei Sehständen: 3 Filme auf 2, beide Dateien erhalten, Position 1800 statt 300 behalten. Dazu Fassungen. MediaFile.VersionLabel liest Mobil, 4K oder Director's Cut aus dem Namen, sonst aus der gemessenen Bildhöhe. Die Liste ist geschlossen, damit eine Fassung nicht YIFY heisst. Und der Fassungsname beendet jetzt den Titel wie jedes andere Beiwerk — vorher wurde aus "The Matrix (1999) - Mobil.mp4" der Film "The Matrix - Mobil", also ein zweiter Eintrag neben dem Original. Genau die Dublette, um die es ging. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d01c5d43f4
commit
63c7ebb6d5
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user