Files
Flaro/server/src/Flaro.Api/Endpoints/MatchEndpoints.cs
T
ScarriffleandClaude Opus 5 a22fa9b58e Drei Zähler für dieselbe Sache, und keiner davon offensichtlich falsch
Während eines Scans stand in der Mediathek 20, in der Verwaltung 14 und
in der Seitenleiste 0. Die Verwaltung frischte alle fünf Runden auf, die
Seitenleiste erst am Ende des Laufs. Jetzt laufen beide auf demselben
Takt, alle drei Runden.

Dazu: während ein Lauf läuft, sind Scannen, Metadaten auffrischen,
Zurücksetzen und Entfernen gesperrt — die ersten beiden prallen am
Server ab und sind nur verwirrend, die anderen beiden löschen Zeilen,
während der Lauf welche schreibt. Umbenennen und Prüfung bleiben
bedienbar. Die Sperre richtet sich nach dem Zustand, den der Server
meldet, nicht nach dem Klick: wer die Seite neu lädt, während ein Lauf
läuft, findet sie ebenfalls gesperrt vor.

Die Reihenfolge der Mediatheken wird jetzt in der Seitenleiste
umsortiert, über „Seitenleiste bearbeiten" neben der Überschrift. Sie
wirkt dort, also gehört der Griff dorthin — vorher schob man in den
Einstellungen und musste danach nachsehen, ob es das war, was man
wollte.

Der Zuordnungsdialog zeigt die Herkunft: Ordner, Zahl der Dateien und
ein paar Namen. Liegt eine Zuordnung völlig daneben, sagt der angezeigte
Titel nichts mehr darüber, welche Datei gemeint ist.

Und SQLite läuft jetzt im WAL-Modus. In der Vorgabe sperrt ein Schreiber
die ganze Datei: solange der Scan schrieb, kam keine Leseanfrage durch.
Genau so sah es aus — Bilddialog und Protokoll liefen während eines
Laufs sporadisch ins Leere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 16:31:50 +02:00

163 lines
7.7 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)
{
// **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);
}
/// <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);