„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>
230 lines
11 KiB
C#
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);
|