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) { // **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(), year ?? film.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) => { 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(), year ?? serie.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) => { 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);