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)
{
// **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 Einwand.Bitte("Ein Titel darf nicht leer sein.");
}
if (titel.Length > 512)
return Einwand.Bitte("Höchstens 512 Zeichen.");
// **Der Sortiertitel bleibt, wenn er von Hand gesetzt wurde.**
// Sonst wäre „Harry Potter 3" nach jeder Titeländerung wieder weg.
var betroffen = art == "movies"
? await db.Movies.Where(m => m.Id == id).ExecuteUpdateAsync(s => s
.SetProperty(m => m.Title, titel)
.SetProperty(m => m.SortTitleValue,
m => m.SortTitlePinned ? 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,
x => x.SortTitlePinned ? x.SortTitleValue : SortTitle.For(titel))
.SetProperty(x => x.TitlePinned, true));
return betroffen > 0 ? Results.NoContent() : Results.NotFound();
}).RequireAuthorization(FlaroPolicies.Administrator);
// **Der Sortiertitel — wonach einsortiert wird, wenn nicht nach dem Titel.**
//
// Eine Reihe steht alphabetisch durcheinander: „…Orden des Phönix" vor
// „…Stein der Weisen", weil O vor S liegt. Mit „Harry Potter 1",
// „Harry Potter 2" stehen sie so, wie man sie sieht.
app.MapPut("/api/v1/{art:regex(^movies|series$)}/{id:guid}/sorttitle", async (
string art, Guid id, TitelRequest anfrage, FlaroContext db) =>
{
var sortiert = anfrage.Title?.Trim();
if (string.IsNullOrEmpty(sortiert))
{
return Results.Problem(
"Ein Sortiertitel darf nicht leer sein. Zum Zurücksetzen gibt es „Wieder automatisch“.",
statusCode: StatusCodes.Status400BadRequest);
}
if (sortiert.Length > 512)
return Einwand.Bitte("Höchstens 512 Zeichen.");
var betroffen = art == "movies"
? await db.Movies.Where(m => m.Id == id).ExecuteUpdateAsync(s => s
.SetProperty(m => m.SortTitleValue, sortiert)
.SetProperty(m => m.SortTitlePinned, true))
: await db.Series.Where(x => x.Id == id).ExecuteUpdateAsync(s => s
.SetProperty(x => x.SortTitleValue, sortiert)
.SetProperty(x => x.SortTitlePinned, true));
return betroffen > 0 ? Results.NoContent() : Results.NotFound();
}).RequireAuthorization(FlaroPolicies.Administrator);
// **Zurück zum errechneten Sortiertitel.**
//
// Anders als beim Titel wird er sofort neu gebildet: er steht nirgends
// sichtbar, und ihn bis zum nächsten Auffrischen falsch stehen zu
// lassen hiesse, dass die Liste solange in der falschen Reihenfolge
// steht.
//
// **Geladen und zurückgeschrieben, nicht in einem Rutsch.** Die Regel
// steht in `SortTitle.For` — einer C#-Methode, die EF Core nicht nach
// SQL übersetzen kann. Der Versuch endete in einer 500; dieselbe Regel
// ein zweites Mal als SQL zu schreiben wäre die Variante, bei der
// beide irgendwann auseinanderlaufen. Es geht um eine Zeile.
app.MapDelete("/api/v1/{art:regex(^movies|series$)}/{id:guid}/sorttitle", async (
string art, Guid id, FlaroContext db) =>
{
if (art == "movies")
{
var film = await db.Movies.FirstOrDefaultAsync(m => m.Id == id);
if (film is null) return Results.NotFound();
film.SortTitleValue = SortTitle.For(film.Title);
film.SortTitlePinned = false;
}
else
{
var serie = await db.Series.FirstOrDefaultAsync(x => x.Id == id);
if (serie is null) return Results.NotFound();
serie.SortTitleValue = SortTitle.For(serie.Title);
serie.SortTitlePinned = false;
}
await db.SaveChangesAsync();
return Results.NoContent();
}).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);
// **Eine Datei aus ihrem Titel herauslösen.**
//
// Zwei Dateien hängen an einem Eintrag, gehören aber zu zwei Filmen:
// „Hanni und Nanni" und „Hanni und Nanni 3" liefen bei der
// Metadatenquelle auf denselben Treffer, und der zweite Film war
// damit nicht mehr zu sehen. Die Riegel dagegen greifen erst beim
// nächsten Lauf — was schon zusammengelegt ist, bleibt es.
//
// Der neue Eintrag bekommt den Titel aus dem Dateinamen und keine
// Kennung: er steht danach unter „Ohne Zuordnung" und lässt sich dort
// in zwei Klicks richtigstellen. Eine Kennung zu raten hiesse, denselben
// Fehler noch einmal zu machen.
app.MapPost("/api/v1/files/{id:guid}/split", async (
Guid id, FlaroContext db, TimeProvider clock) =>
{
var datei = await db.MediaFiles.FirstOrDefaultAsync(f => f.Id == id);
if (datei is null) return Results.NotFound();
if (datei.MovieId is not { } alt)
{
return Results.Problem(
"Nur eine Filmdatei lässt sich herauslösen. Bei Serien entscheidet "
+ "die Folgennummer, zu welcher Folge eine Datei gehört.",
statusCode: StatusCodes.Status400BadRequest);
}
var geschwister = await db.MediaFiles.CountAsync(f => f.MovieId == alt);
if (geschwister < 2)
{
return Results.Problem(
"An diesem Titel hängt nur diese eine Datei — da gibt es nichts zu trennen.",
statusCode: StatusCodes.Status409Conflict);
}
var name = FileNames.ParseMovie(datei.Path);
var bisher = await db.Movies.AsNoTracking().FirstAsync(m => m.Id == alt);
var neu = new Movie
{
LibraryId = datei.LibraryId,
ParsedTitle = name.Title,
ParsedYear = name.Year,
Title = name.Title,
SortTitleValue = SortTitle.For(name.Title),
Year = name.Year,
// **Sofort sichtbar.** Wer etwas herauslöst, will es sehen —
// nicht erst freigeben müssen.
PublishAt = bisher.PublishAt ?? clock.GetUtcNow(),
};
db.Movies.Add(neu);
datei.MovieId = neu.Id;
await db.SaveChangesAsync();
return Results.Ok(new MatchState(null, null, false, neu.Title, neu.Year, Id: neu.Id));
}).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(),
// **Kein Jahr heisst: ohne Jahr suchen.**
//
// Die Maske trägt das Jahr aus dem Dateinamen schon ein; wer
// es herausnimmt, sagt damit, dass er es nicht weiss. Hier
// stand ein Rückfall auf eben dieses Jahr — das leere Feld
// hatte also keine Wirkung, und ein falsch benannter Film
// liess sich nicht finden. „Plan B (2010)" heisst bei TMDB
// „DivertiMento – Plan B" und ist von 2011; mit dem Jahr aus
// dem Dateinamen kam nie ein Treffer.
//
// Davor stand hier `film.Year`, also das Jahr aus der
// Zuordnung, die gerade korrigiert werden soll — ein falscher
// Treffer verhinderte so seine eigene Korrektur. Auch das ist
// damit erledigt.
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, Zusammenlegung zusammen) =>
{
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 Einwand.Fehlt($"TMDB kennt den Film {anfrage.TmdbId} nicht.");
// **Hat die Kennung schon jemand, ist es derselbe Film.**
//
// Ein Film kommt je Mediathek genau einmal vor — das erzwingt ein
// Eindeutigkeitsindex. Die Kennung blind zu setzen liess ihn
// zuschlagen, und der Benutzer bekam „An error occurred while
// processing your request." zu sehen: eine englische
// Allgemeinmeldung für einen Fall, der gar kein Fehler ist.
//
// Zwei Dateien, die denselben Film meinen, gehören zusammengelegt
// — genau das tut der Scan am Ende jedes Laufs auch. Der
// Sehfortschritt wird dabei gefaltet, nichts geht verloren.
var schonDa = await db.Movies
.Where(m => m.LibraryId == film.LibraryId && m.TmdbId == anfrage.TmdbId && m.Id != id)
.Select(m => m.Id)
.FirstOrDefaultAsync();
if (schonDa != Guid.Empty)
{
await anwender.ApplyAsync(
(await db.Movies.FirstAsync(m => m.Id == schonDa)), gefunden);
await zusammen.FilmVerschmelzenAsync(sieger: schonDa, verlierer: id);
var sieger = await db.Movies.AsNoTracking().FirstAsync(m => m.Id == schonDa);
return Results.Ok(new MatchState(
sieger.TmdbId, sieger.ImdbId, sieger.MatchLocked, sieger.Title, sieger.Year,
Id: sieger.Id, Merged: true));
}
await anwender.ApplyAsync(film, gefunden);
return Results.Ok(new MatchState(
film.TmdbId, film.ImdbId, film.MatchLocked, film.Title, film.Year, Id: film.Id));
}).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: ein leeres Jahr ist eine Angabe, kein Fehlen.
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, Zusammenlegung zusammen) =>
{
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 Einwand.Fehlt($"TMDB kennt die Serie {anfrage.TmdbId} nicht.");
// Siehe bei den Filmen: dieselbe Kennung heisst dieselbe Serie,
// und zwei Ordner derselben Serie gehören zusammengelegt.
var schonDa = await db.Series
.Where(x => x.LibraryId == serie.LibraryId && x.TmdbId == anfrage.TmdbId && x.Id != id)
.Select(x => x.Id)
.FirstOrDefaultAsync();
if (schonDa != Guid.Empty)
{
await anwender.ApplyAsync(await db.Series.FirstAsync(x => x.Id == schonDa), gefunden);
await zusammen.SerieVerschmelzenAsync(sieger: schonDa, verlierer: id);
var sieger = await db.Series.AsNoTracking().FirstAsync(x => x.Id == schonDa);
return Results.Ok(new MatchState(
sieger.TmdbId, sieger.ImdbId, sieger.MatchLocked, sieger.Title, sieger.Year,
Id: sieger.Id, Merged: true));
}
await anwender.ApplyAsync(serie, gefunden);
return Results.Ok(new MatchState(
serie.TmdbId, serie.ImdbId, serie.MatchLocked, serie.Title, serie.Year, Id: serie.Id));
}).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);