using Flaro.Core.Library; using Flaro.Core.Metadata; using Flaro.Data; using Microsoft.EntityFrameworkCore; namespace Flaro.Api.Endpoints; /// Eine falsche Zuordnung von Hand richtigstellen. /// /// /// /// Weil Raten manchmal danebengeht. Die Suche nach „Dark" lieferte /// „Dark Shadows" von 1966 — ohne Jahresangabe gewinnt bei TMDB der /// beliebteste Treffer. Dagegen hilft keine bessere Heuristik, sondern nur /// die Möglichkeit, es richtigzustellen. /// /// /// Eine Korrektur wird festgeschrieben. In der Datenbank und in /// der NFO-Datei neben dem Film, mit lockdata. Danach sucht kein Lauf /// mehr danach — und ein Neuaufbau der Datenbank oder ein Umzug auf einen /// anderen Server findet die Entscheidung wieder. /// /// public static class MatchEndpoints { public static void MapMatching(this IEndpointRouteBuilder app) { // **Den Titel von Hand setzen — und festschreiben.** // // TMDB kennt nicht jeden Titel so, wie er im Haushalt heisst, und bei // Mitschnitten gibt es gar keinen. Ohne diesen Weg bleibt nur der // Dateiname oder ein falscher Treffer. // // Festgeschrieben wie Plakat und Hintergrund: sonst überschreibt das // nächste Auffrischen die Korrektur, und man tippt sie nach jedem Lauf // neu. app.MapPut("/api/v1/{art:regex(^movies|series$)}/{id:guid}/title", async ( string art, Guid id, TitelRequest anfrage, FlaroContext db) => { var titel = anfrage.Title?.Trim(); if (string.IsNullOrEmpty(titel)) { return Einwand.Bitte("Ein Titel darf nicht leer sein."); } if (titel.Length > 512) return Einwand.Bitte("Höchstens 512 Zeichen."); // **Der Sortiertitel bleibt, wenn er von Hand gesetzt wurde.** // Sonst wäre „Harry Potter 3" nach jeder Titeländerung wieder weg. var betroffen = art == "movies" ? await db.Movies.Where(m => m.Id == id).ExecuteUpdateAsync(s => s .SetProperty(m => m.Title, titel) .SetProperty(m => m.SortTitleValue, m => m.SortTitlePinned ? m.SortTitleValue : SortTitle.For(titel)) .SetProperty(m => m.TitlePinned, true)) : await db.Series.Where(x => x.Id == id).ExecuteUpdateAsync(s => s .SetProperty(x => x.Title, titel) .SetProperty(x => x.SortTitleValue, x => x.SortTitlePinned ? x.SortTitleValue : SortTitle.For(titel)) .SetProperty(x => x.TitlePinned, true)); return betroffen > 0 ? Results.NoContent() : Results.NotFound(); }).RequireAuthorization(FlaroPolicies.Administrator); // **Der Sortiertitel — wonach einsortiert wird, wenn nicht nach dem Titel.** // // Eine Reihe steht alphabetisch durcheinander: „…Orden des Phönix" vor // „…Stein der Weisen", weil O vor S liegt. Mit „Harry Potter 1", // „Harry Potter 2" stehen sie so, wie man sie sieht. app.MapPut("/api/v1/{art:regex(^movies|series$)}/{id:guid}/sorttitle", async ( string art, Guid id, TitelRequest anfrage, FlaroContext db) => { var sortiert = anfrage.Title?.Trim(); if (string.IsNullOrEmpty(sortiert)) { return Results.Problem( "Ein Sortiertitel darf nicht leer sein. Zum Zurücksetzen gibt es „Wieder automatisch“.", statusCode: StatusCodes.Status400BadRequest); } if (sortiert.Length > 512) return Einwand.Bitte("Höchstens 512 Zeichen."); var betroffen = art == "movies" ? await db.Movies.Where(m => m.Id == id).ExecuteUpdateAsync(s => s .SetProperty(m => m.SortTitleValue, sortiert) .SetProperty(m => m.SortTitlePinned, true)) : await db.Series.Where(x => x.Id == id).ExecuteUpdateAsync(s => s .SetProperty(x => x.SortTitleValue, sortiert) .SetProperty(x => x.SortTitlePinned, true)); return betroffen > 0 ? Results.NoContent() : Results.NotFound(); }).RequireAuthorization(FlaroPolicies.Administrator); // **Zurück zum errechneten Sortiertitel.** // // Anders als beim Titel wird er sofort neu gebildet: er steht nirgends // sichtbar, und ihn bis zum nächsten Auffrischen falsch stehen zu // lassen hiesse, dass die Liste solange in der falschen Reihenfolge // steht. // // **Geladen und zurückgeschrieben, nicht in einem Rutsch.** Die Regel // steht in `SortTitle.For` — einer C#-Methode, die EF Core nicht nach // SQL übersetzen kann. Der Versuch endete in einer 500; dieselbe Regel // ein zweites Mal als SQL zu schreiben wäre die Variante, bei der // beide irgendwann auseinanderlaufen. Es geht um eine Zeile. app.MapDelete("/api/v1/{art:regex(^movies|series$)}/{id:guid}/sorttitle", async ( string art, Guid id, FlaroContext db) => { if (art == "movies") { var film = await db.Movies.FirstOrDefaultAsync(m => m.Id == id); if (film is null) return Results.NotFound(); film.SortTitleValue = SortTitle.For(film.Title); film.SortTitlePinned = false; } else { var serie = await db.Series.FirstOrDefaultAsync(x => x.Id == id); if (serie is null) return Results.NotFound(); serie.SortTitleValue = SortTitle.For(serie.Title); serie.SortTitlePinned = false; } await db.SaveChangesAsync(); return Results.NoContent(); }).RequireAuthorization(FlaroPolicies.Administrator); // **Zurück zum automatischen Titel.** // // Ohne diesen Weg gäbe es keinen zurück, ausser den alten Namen zu // erraten. Der Titel selbst bleibt stehen, bis das nächste Auffrischen // ihn holt — sonst stünde dort bis dahin gar nichts. app.MapDelete("/api/v1/{art:regex(^movies|series$)}/{id:guid}/title", async ( string art, Guid id, FlaroContext db) => { var betroffen = art == "movies" ? await db.Movies.Where(m => m.Id == id) .ExecuteUpdateAsync(s => s.SetProperty(m => m.TitlePinned, false)) : await db.Series.Where(x => x.Id == id) .ExecuteUpdateAsync(s => s.SetProperty(x => x.TitlePinned, false)); return betroffen > 0 ? Results.NoContent() : Results.NotFound(); }).RequireAuthorization(FlaroPolicies.Administrator); // **Eine Datei aus ihrem Titel herauslösen.** // // Zwei Dateien hängen an einem Eintrag, gehören aber zu zwei Filmen: // „Hanni und Nanni" und „Hanni und Nanni 3" liefen bei der // Metadatenquelle auf denselben Treffer, und der zweite Film war // damit nicht mehr zu sehen. Die Riegel dagegen greifen erst beim // nächsten Lauf — was schon zusammengelegt ist, bleibt es. // // Der neue Eintrag bekommt den Titel aus dem Dateinamen und keine // Kennung: er steht danach unter „Ohne Zuordnung" und lässt sich dort // in zwei Klicks richtigstellen. Eine Kennung zu raten hiesse, denselben // Fehler noch einmal zu machen. app.MapPost("/api/v1/files/{id:guid}/split", async ( Guid id, FlaroContext db, TimeProvider clock) => { var datei = await db.MediaFiles.FirstOrDefaultAsync(f => f.Id == id); if (datei is null) return Results.NotFound(); if (datei.MovieId is not { } alt) { return Results.Problem( "Nur eine Filmdatei lässt sich herauslösen. Bei Serien entscheidet " + "die Folgennummer, zu welcher Folge eine Datei gehört.", statusCode: StatusCodes.Status400BadRequest); } var geschwister = await db.MediaFiles.CountAsync(f => f.MovieId == alt); if (geschwister < 2) { return Results.Problem( "An diesem Titel hängt nur diese eine Datei — da gibt es nichts zu trennen.", statusCode: StatusCodes.Status409Conflict); } var name = FileNames.ParseMovie(datei.Path); var bisher = await db.Movies.AsNoTracking().FirstAsync(m => m.Id == alt); var neu = new Movie { LibraryId = datei.LibraryId, ParsedTitle = name.Title, ParsedYear = name.Year, Title = name.Title, SortTitleValue = SortTitle.For(name.Title), Year = name.Year, // **Sofort sichtbar.** Wer etwas herauslöst, will es sehen — // nicht erst freigeben müssen. PublishAt = bisher.PublishAt ?? clock.GetUtcNow(), }; db.Movies.Add(neu); datei.MovieId = neu.Id; await db.SaveChangesAsync(); return Results.Ok(new MatchState(null, null, false, neu.Title, neu.Year, Id: neu.Id)); }).RequireAuthorization(FlaroPolicies.Administrator); // **Woher dieser Titel kommt.** // // Steht im Dialog über der Suche. Wenn eine Zuordnung völlig daneben // liegt — „Dark Shadows" von 1966 statt „Dark" —, sagt der angezeigte // Titel nichts mehr darüber, welche Datei da eigentlich gemeint ist. // Der Ordner sagt es, und zwar sofort. app.MapGet("/api/v1/{art:regex(^movies|series$)}/{id:guid}/herkunft", async ( string art, Guid id, FlaroContext db) => { var dateien = art == "movies" ? db.MediaFiles.AsNoTracking().Where(f => f.MovieId == id) : db.MediaFiles.AsNoTracking().Where(f => f.EpisodeId != null && db.Episodes.Any(e => e.Id == f.EpisodeId && e.SeriesId == id)); // **Gezählt wird vollständig, gezeigt nur eine Handvoll.** Hier // stand erst ein `Take(50)` vor dem Zählen — bei 1200 Folgen // behauptete der Dialog dann „50 Dateien". Eine Zahl, die bei // genau dem Titel falsch wird, bei dem man hinsieht. // // **Ein Zug, dann im Speicher.** Der Ordner aus einem Pfad zu // schneiden lässt sich nicht nach SQL übersetzen — der Versuch // endete in einem 500er. Die Pfade eines einzelnen Titels sind // auch bei 1200 Folgen ein paar hundert Kilobyte; das ist // billiger als eine Abfrage, die nicht läuft. var pfade = await dateien.Select(f => f.Path).ToListAsync(); if (pfade.Count == 0) return Results.Ok(new Herkunft(null, 0, [])); var ordner = pfade .Select(p => Path.GetDirectoryName(p) ?? "") .Distinct(StringComparer.Ordinal) .ToList(); return Results.Ok(new Herkunft( ordner.Count == 1 ? ordner[0] : GemeinsamerAnfang(ordner), pfade.Count, pfade.Order(StringComparer.Ordinal) .Take(6) .Select(Path.GetFileName) .Where(n => n is not null) .ToList()!)); }).RequireAuthorization(FlaroPolicies.Administrator); app.MapGet("/api/v1/movies/{id:guid}/matches", async ( Guid id, FlaroContext db, IMetadataProvider anbieter, string? query, int? year) => { var film = await db.Movies.AsNoTracking().FirstOrDefaultAsync(m => m.Id == id); if (film is null) return Results.NotFound(); // Ohne eigene Suchworte das, was aus dem Dateinamen gelesen wurde // — nicht der angezeigte Titel, der ja gerade der falsche ist. var vorschläge = await anbieter.SearchMoviesAsync( string.IsNullOrWhiteSpace(query) ? film.ParsedTitle : query.Trim(), // **Kein Jahr heisst: ohne Jahr suchen.** // // Die Maske trägt das Jahr aus dem Dateinamen schon ein; wer // es herausnimmt, sagt damit, dass er es nicht weiss. Hier // stand ein Rückfall auf eben dieses Jahr — das leere Feld // hatte also keine Wirkung, und ein falsch benannter Film // liess sich nicht finden. „Plan B (2010)" heisst bei TMDB // „DivertiMento – Plan B" und ist von 2011; mit dem Jahr aus // dem Dateinamen kam nie ein Treffer. // // Davor stand hier `film.Year`, also das Jahr aus der // Zuordnung, die gerade korrigiert werden soll — ein falscher // Treffer verhinderte so seine eigene Korrektur. Auch das ist // damit erledigt. year); return Results.Ok(vorschläge.Select(v => new MatchCandidate(v.ProviderId, v.Title, v.Year, v.Overview, v.Poster?.Url)).ToList()); }).RequireAuthorization(FlaroPolicies.Administrator); app.MapPut("/api/v1/movies/{id:guid}/match", async ( Guid id, ApplyMatchRequest anfrage, FlaroContext db, IMetadataProvider anbieter, MatchApplier anwender, Zusammenlegung zusammen) => { var film = await db.Movies.FirstOrDefaultAsync(m => m.Id == id); if (film is null) return Results.NotFound(); var gefunden = await anbieter.GetMovieAsync(anfrage.TmdbId); if (gefunden is null) return Einwand.Fehlt($"TMDB kennt den Film {anfrage.TmdbId} nicht."); // **Hat die Kennung schon jemand, ist es derselbe Film.** // // Ein Film kommt je Mediathek genau einmal vor — das erzwingt ein // Eindeutigkeitsindex. Die Kennung blind zu setzen liess ihn // zuschlagen, und der Benutzer bekam „An error occurred while // processing your request." zu sehen: eine englische // Allgemeinmeldung für einen Fall, der gar kein Fehler ist. // // Zwei Dateien, die denselben Film meinen, gehören zusammengelegt // — genau das tut der Scan am Ende jedes Laufs auch. Der // Sehfortschritt wird dabei gefaltet, nichts geht verloren. var schonDa = await db.Movies .Where(m => m.LibraryId == film.LibraryId && m.TmdbId == anfrage.TmdbId && m.Id != id) .Select(m => m.Id) .FirstOrDefaultAsync(); if (schonDa != Guid.Empty) { await anwender.ApplyAsync( (await db.Movies.FirstAsync(m => m.Id == schonDa)), gefunden); await zusammen.FilmVerschmelzenAsync(sieger: schonDa, verlierer: id); var sieger = await db.Movies.AsNoTracking().FirstAsync(m => m.Id == schonDa); return Results.Ok(new MatchState( sieger.TmdbId, sieger.ImdbId, sieger.MatchLocked, sieger.Title, sieger.Year, Id: sieger.Id, Merged: true)); } await anwender.ApplyAsync(film, gefunden); return Results.Ok(new MatchState( film.TmdbId, film.ImdbId, film.MatchLocked, film.Title, film.Year, Id: film.Id)); }).RequireAuthorization(FlaroPolicies.Administrator); app.MapGet("/api/v1/series/{id:guid}/matches", async ( Guid id, FlaroContext db, IMetadataProvider anbieter, string? query, int? year) => { var serie = await db.Series.AsNoTracking().FirstOrDefaultAsync(s => s.Id == id); if (serie is null) return Results.NotFound(); var vorschläge = await anbieter.SearchSeriesAsync( string.IsNullOrWhiteSpace(query) ? serie.ParsedTitle : query.Trim(), // Siehe oben: ein leeres Jahr ist eine Angabe, kein Fehlen. year); return Results.Ok(vorschläge.Select(v => new MatchCandidate(v.ProviderId, v.Title, v.Year, v.Overview, v.Poster?.Url)).ToList()); }).RequireAuthorization(FlaroPolicies.Administrator); app.MapPut("/api/v1/series/{id:guid}/match", async ( Guid id, ApplyMatchRequest anfrage, FlaroContext db, IMetadataProvider anbieter, MatchApplier anwender, Zusammenlegung zusammen) => { var serie = await db.Series.FirstOrDefaultAsync(s => s.Id == id); if (serie is null) return Results.NotFound(); var gefunden = await anbieter.GetSeriesAsync(anfrage.TmdbId); if (gefunden is null) return Einwand.Fehlt($"TMDB kennt die Serie {anfrage.TmdbId} nicht."); // Siehe bei den Filmen: dieselbe Kennung heisst dieselbe Serie, // und zwei Ordner derselben Serie gehören zusammengelegt. var schonDa = await db.Series .Where(x => x.LibraryId == serie.LibraryId && x.TmdbId == anfrage.TmdbId && x.Id != id) .Select(x => x.Id) .FirstOrDefaultAsync(); if (schonDa != Guid.Empty) { await anwender.ApplyAsync(await db.Series.FirstAsync(x => x.Id == schonDa), gefunden); await zusammen.SerieVerschmelzenAsync(sieger: schonDa, verlierer: id); var sieger = await db.Series.AsNoTracking().FirstAsync(x => x.Id == schonDa); return Results.Ok(new MatchState( sieger.TmdbId, sieger.ImdbId, sieger.MatchLocked, sieger.Title, sieger.Year, Id: sieger.Id, Merged: true)); } await anwender.ApplyAsync(serie, gefunden); return Results.Ok(new MatchState( serie.TmdbId, serie.ImdbId, serie.MatchLocked, serie.Title, serie.Year, Id: serie.Id)); }).RequireAuthorization(FlaroPolicies.Administrator); } /// Der längste Pfad, mit dem alle anfangen — oder null. /// /// /// Verglichen wird nach Ordnerbestandteilen, nicht nach Zeichen: sonst /// ergäben /media/Serien/Dark und /media/Serien/Darkwing den /// gemeinsamen Anfang /media/Serien/Dark, und der bezeichnet einen /// Ordner, in dem nur eines von beidem liegt. /// private static string? GemeinsamerAnfang(IReadOnlyList pfade) { if (pfade.Count == 0) return null; var teile = pfade.Select(p => p.Split('/', StringSplitOptions.RemoveEmptyEntries)).ToList(); var gemeinsam = new List(); for (var i = 0; i < teile[0].Length; i++) { var stueck = teile[0][i]; if (teile.Any(t => i >= t.Length || t[i] != stueck)) break; gemeinsam.Add(stueck); } return gemeinsam.Count == 0 ? null : "/" + string.Join('/', gemeinsam); } } /// Woher ein Titel im Dateisystem stammt. /// /// Der gemeinsame Ordner aller Dateien; null, wenn es keinen gibt. /// Wie viele Dateien an diesem Titel hängen. /// Ein paar Dateinamen — mehr braucht niemand zum Wiedererkennen. public sealed record Herkunft(string? Ordner, int Dateien, IReadOnlyList Beispiele);