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); } } }