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