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