Files
Flaro/server/src/Flaro.Api/Endpoints/MatchEndpoints.cs
T
ScarriffleandClaude Opus 5 0c07d4efc7 Ein falscher Treffer verhinderte seine eigene Korrektur
„Doctor Strange" war fälschlich auf ein Making-of von 2022 gelaufen. Beim
Korrigieren hängte die Vorschlagssuche `year=2022` an — aus genau der
Zuordnung, die weg sollte — und lieferte nur noch Titel von 2022. Der
richtige Film von 2016 konnte gar nicht erscheinen, egal wie oft man
suchte.

An TMDB nachgemessen:

  query='Doctor Strange' ohne Jahr     → 2016 Doctor Strange, 2022 Multiverse …
  query='Doctor Strange' mit year=2022 → genau die drei Treffer aus der Meldung

Gesucht wird jetzt mit dem Jahr aus dem Dateinamen, nie mit dem aus dem
Treffer. Die Oberfläche trägt ebenfalls keines mehr vor: das angezeigte
stammt aus derselben falschen Zuordnung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 21:14:55 +02:00

230 lines
11 KiB
C#

using Flaro.Core.Library;
using Flaro.Core.Metadata;
using Flaro.Data;
using Microsoft.EntityFrameworkCore;
namespace Flaro.Api.Endpoints;
/// <summary>Eine falsche Zuordnung von Hand richtigstellen.</summary>
///
/// <remarks>
/// <para>
/// <b>Weil Raten manchmal danebengeht.</b> 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.
/// </para>
/// <para>
/// <b>Eine Korrektur wird festgeschrieben.</b> In der Datenbank <i>und</i> in
/// der NFO-Datei neben dem Film, mit <c>lockdata</c>. Danach sucht kein Lauf
/// mehr danach — und ein Neuaufbau der Datenbank oder ein Umzug auf einen
/// anderen Server findet die Entscheidung wieder.
/// </para>
/// </remarks>
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<string, string[]>
{
["title"] = ["Ein Titel darf nicht leer sein."],
});
}
if (titel.Length > 512)
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["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);
}
/// <summary>Der längste Pfad, mit dem alle anfangen — oder <c>null</c>.</summary>
///
/// <remarks>
/// Verglichen wird nach Ordnerbestandteilen, nicht nach Zeichen: sonst
/// ergäben <c>/media/Serien/Dark</c> und <c>/media/Serien/Darkwing</c> den
/// gemeinsamen Anfang <c>/media/Serien/Dark</c>, und der bezeichnet einen
/// Ordner, in dem nur eines von beidem liegt.
/// </remarks>
private static string? GemeinsamerAnfang(IReadOnlyList<string> pfade)
{
if (pfade.Count == 0) return null;
var teile = pfade.Select(p => p.Split('/', StringSplitOptions.RemoveEmptyEntries)).ToList();
var gemeinsam = new List<string>();
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);
}
}
/// <summary>Woher ein Titel im Dateisystem stammt.</summary>
///
/// <param name="Ordner">Der gemeinsame Ordner aller Dateien; <c>null</c>, wenn es keinen gibt.</param>
/// <param name="Dateien">Wie viele Dateien an diesem Titel hängen.</param>
/// <param name="Beispiele">Ein paar Dateinamen — mehr braucht niemand zum Wiedererkennen.</param>
public sealed record Herkunft(string? Ordner, int Dateien, IReadOnlyList<string> Beispiele);