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 Results.ValidationProblem(new Dictionary { ["title"] = ["Ein Titel darf nicht leer sein."], }); } if (titel.Length > 512) return Results.ValidationProblem(new Dictionary { ["title"] = ["Höchstens 512 Zeichen."], }); var betroffen = art == "movies" ? await db.Movies.Where(m => m.Id == id).ExecuteUpdateAsync(s => s .SetProperty(m => m.Title, titel) .SetProperty(m => 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, SortTitle.For(titel)) .SetProperty(x => x.TitlePinned, true)); return betroffen > 0 ? Results.NoContent() : Results.NotFound(); }).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); // **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(), // **Das Jahr aus dem Dateinamen, nicht das aus der Zuordnung.** // // Hier stand `film.Year` — und das kommt von genau der // Zuordnung, die gerade korrigiert werden soll. „Doctor // Strange" war fälschlich auf ein Making-of von 2022 // gelaufen; die Suche hängte daraufhin `year=2022` an, und der // richtige Film von 2016 konnte gar nicht mehr auftauchen. Ein // falscher Treffer verhinderte so seine eigene Korrektur. year ?? film.ParsedYear); 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) => { 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 Results.Problem($"TMDB kennt den Film {anfrage.TmdbId} nicht.", statusCode: StatusCodes.Status404NotFound); await anwender.ApplyAsync(film, gefunden); return Results.Ok(new MatchState(film.TmdbId, film.ImdbId, film.MatchLocked, film.Title, film.Year)); }).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: aus dem Dateinamen, nicht aus der Zuordnung. year ?? serie.ParsedYear); 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) => { 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 Results.Problem($"TMDB kennt die Serie {anfrage.TmdbId} nicht.", statusCode: StatusCodes.Status404NotFound); await anwender.ApplyAsync(serie, gefunden); return Results.Ok(new MatchState(serie.TmdbId, serie.ImdbId, serie.MatchLocked, serie.Title, serie.Year)); }).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);