Files
Flaro/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs
T
ScarriffleandClaude Opus 5 557708b769 Ein Klick, der nichts tat, und niemand sagte warum
Eine Serienkachel kannte der Server als Titel nicht: „als gesehen markieren"
lief in eine 404, und weil das Kontextmenü die Ablehnung verschluckte, geschah
sichtbar gar nichts. Dasselbe beim Favoriten. UserItemState bekommt eine
Serienkennung, das Häkchen zieht alle Folgen mit, und was wirft, erscheint als
Meldung statt in der Browserkonsole.

Dazu eine Mehrfachauswahl in der Mediathek — gesehen, ungesehen, Metadaten
neu holen für das Gewählte, in einer Anfrage statt in zweihundert. Ein neues
Plakat steht sofort auf der Kachel, und ein Titel, den es nicht mehr gibt,
endet nicht mehr in einer Sackgasse.

Gemessen: ein POST auf /refresh ohne Rumpf antwortete nach der Erweiterung mit
404 — also genau der Aufruf „alles auffrischen", den es länger gibt als die
Auswahl. Der Rumpf wird jetzt von Hand gelesen, und beide Formen stehen in
einer Prüfung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-05 18:28:33 +02:00

680 lines
33 KiB
C#

using Flaro.Core.Library;
using Flaro.Core.Media;
using System.Security.Claims;
using Flaro.Core.Playback;
using Flaro.Data;
using Microsoft.EntityFrameworkCore;
namespace Flaro.Api.Endpoints;
public static class LibraryContentEndpoints
{
/// <summary>Mehr als das liefert keine Anfrage — auch wenn sie danach fragt.</summary>
///
/// <remarks>
/// Ohne Obergrenze holt ein <c>?take=100000</c> die ganze Mediathek in
/// einem Zug, und zwar sowohl in den Arbeitsspeicher des Servers als auch
/// über die Leitung. Eine Grenze, die der Aufrufer setzt, ist keine.
/// </remarks>
private const int HöchstensJeSeite = 200;
/// <summary>Wie viele Titel eine Auswahl höchstens umfasst.</summary>
private const int HöchstensJeZug = 1000;
/// <summary>Welche Titel im Rumpf stehen — oder <c>null</c>, wenn keiner da ist.</summary>
///
/// <remarks>
/// <b>Ein fehlender Rumpf ist kein Fehler, sondern die Voreinstellung.</b>
/// „Alles auffrischen" schickt nichts mit, und das muss so bleiben: der
/// Knopf dafür gibt es länger als die Auswahl.
/// </remarks>
private static async Task<IReadOnlyList<Guid>?> Auswahl(HttpRequest anfrage)
{
if (anfrage.ContentLength is null or 0) return null;
if (!anfrage.HasJsonContentType()) return null;
try
{
var gelesen = await anfrage.ReadFromJsonAsync<RefreshRequest>();
return gelesen?.Ids;
}
catch (System.Text.Json.JsonException)
{
// Unlesbarer Rumpf heisst: keine Auswahl. Alles aufzufrischen ist
// hier die harmlosere Auslegung — und sie kostet nur Zeit.
return null;
}
}
/// <summary>Wonach eine Liste geordnet wird.</summary>
///
/// <remarks>
/// <b>Als Wort, nicht als Spaltenname.</b> „added" sagt, was gemeint ist;
/// `AddedAt desc` verriete den Aufbau der Datenbank und wäre beim
/// nächsten Umbau falsch. Unbekanntes fällt auf den Titel zurück, statt
/// die Anfrage abzulehnen — eine Liste in falscher Reihenfolge ist
/// besser als keine.
/// </remarks>
private enum Reihenfolge { Title, Added, Year }
private static Reihenfolge Ordnung(string? wunsch) => wunsch?.ToLowerInvariant() switch
{
"added" => Reihenfolge.Added,
"year" => Reihenfolge.Year,
_ => Reihenfolge.Title,
};
public static void MapLibraryContent(this IEndpointRouteBuilder app)
{
MapScan(app);
MapBrowse(app);
}
/// <summary>Holt die Spuren aller genannten Dateien in einer Abfrage.</summary>
///
/// <remarks>
/// Je Datei einzeln zu fragen hiesse: fünfzig Titel, fünfzig Abfragen —
/// und SQLite führt die nacheinander aus.
/// </remarks>
private static async Task<ILookup<Guid, MediaStreamResponse>> SpurenZu(FlaroContext db, List<Guid> dateien)
{
if (dateien.Count == 0) return Array.Empty<(Guid, MediaStreamResponse)>().ToLookup(x => x.Item1, x => x.Item2);
var roh = await db.MediaStreams.AsNoTracking()
.Where(s => dateien.Contains(s.MediaFileId))
.OrderBy(s => s.Index)
.ToListAsync();
return roh.ToLookup(s => s.MediaFileId, s => new MediaStreamResponse(
s.Index, s.Kind.ToString(), s.Codec, s.Language, s.Title,
s.IsDefault, s.IsForced, s.Channels, s.Width, s.Height,
Display: s.Kind switch
{
MediaStreamKind.Video => Benennung.Bildspur(s.Codec, s.Height),
MediaStreamKind.Audio => Benennung.Tonspur(s.Language, s.Codec, s.Channels, s.Title),
_ => Benennung.Untertitelspur(s.Language, s.IsForced, fuerHoergeschaedigte: false),
}));
}
private static MediaFileResponse Datei(
Guid id, string pfad, long bytes, string? schachtel, TimeSpan? dauer,
ILookup<Guid, MediaStreamResponse> spuren, string? fassung = null) =>
new(id, System.IO.Path.GetFileName(pfad), bytes, schachtel, dauer?.TotalSeconds,
spuren[id].ToList(),
// Steht sie nicht in der Datenbank, wird sie aus dem Pfad gelesen:
// ein Bestand von vor dieser Änderung soll nicht erst neu gescannt
// werden müssen, um „Mobil" zu zeigen.
fassung ?? FileNames.Fassung(pfad));
/// <summary>Die Genres eines Titels, in der Reihenfolge der Quelle.</summary>
private static async Task<IReadOnlyList<string>> GenresZu(FlaroContext db, Guid? film, Guid? serie) =>
await db.GenreLinks.AsNoTracking()
.Where(l => film != null ? l.MovieId == film : l.SeriesId == serie)
.Join(db.Genres.AsNoTracking(), l => l.GenreId, g => g.Id, (_, g) => g.Name)
.ToListAsync();
/// <summary>Besetzung und Stab — Darsteller zuerst, in ihrer Reihenfolge.</summary>
private static async Task<IReadOnlyList<CreditResponse>> MitwirkendeZu(
FlaroContext db, Guid? film, Guid? serie) =>
await db.Credits.AsNoTracking()
.Where(c => film != null ? c.MovieId == film : c.SeriesId == serie)
.Join(db.People.AsNoTracking(), c => c.PersonId, p => p.Id, (c, p) => new { c, p })
.OrderBy(x => x.c.Role).ThenBy(x => x.c.Order)
.Select(x => new CreditResponse(
x.p.Id, x.p.Name, x.c.Role.ToString(), x.c.Character, x.p.Image))
.ToListAsync();
/// <summary>Die Stände dieses Benutzers zu den genannten Titeln.</summary>
///
/// <remarks>
/// In einer Abfrage für die ganze Seite. Ein Titel ohne Zeile hat keinen
/// Stand — das ist kein Fehler, sondern der Normalfall für alles, was
/// noch niemand angefasst hat, und die Antwort trägt dort <c>null</c>.
/// </remarks>
private static async Task<Dictionary<Guid, ItemStateResponse>> ZuständeZu(
FlaroContext db, ClaimsPrincipal wer,
List<Guid>? filme = null, List<Guid>? folgen = null, List<Guid>? serien = null)
{
if (PlaybackEndpoints.Benutzer(wer) is not { } benutzer) return [];
var gesucht = (filme ?? folgen ?? serien)!;
if (gesucht.Count == 0) return [];
var zeilen = await db.UserItemStates.AsNoTracking()
.Where(z => z.UserId == benutzer)
.Where(z => filme != null
? z.MovieId != null && gesucht.Contains(z.MovieId!.Value)
: folgen != null
? z.EpisodeId != null && gesucht.Contains(z.EpisodeId!.Value)
: z.SeriesId != null && gesucht.Contains(z.SeriesId!.Value))
.ToListAsync();
return zeilen.ToDictionary(
z => (filme != null ? z.MovieId : folgen != null ? z.EpisodeId : z.SeriesId)!.Value,
PlaybackEndpoints.Abbild);
}
/// <summary>Welche der genannten Folgen dieser Benutzer gesehen hat.</summary>
private static async Task<HashSet<Guid>> GeseheneFolgen(
FlaroContext db, ClaimsPrincipal wer, List<Guid> folgen)
{
if (PlaybackEndpoints.Benutzer(wer) is not { } benutzer || folgen.Count == 0) return [];
var ids = await db.UserItemStates.AsNoTracking()
.Where(z => z.UserId == benutzer && z.Watched
&& z.EpisodeId != null && folgen.Contains(z.EpisodeId!.Value))
.Select(z => z.EpisodeId!.Value)
.ToListAsync();
return [.. ids];
}
private static void MapScan(IEndpointRouteBuilder app)
{
app.MapPost("/api/v1/libraries/{id:guid}/scan", async (
Guid id, FlaroContext db, ScanCoordinator koordinator) =>
{
if (!await db.Libraries.AnyAsync(l => l.Id == id)) return Results.NotFound();
return koordinator.TryStart(id)
? Results.AcceptedAtRoute("Scanstand", new { id })
: Results.Problem(
"Für diese Bibliothek läuft bereits ein Scan.",
statusCode: StatusCodes.Status409Conflict);
}).RequireAuthorization(FlaroPolicies.Administrator);
// **Metadaten neu holen — für alles, nicht nur für Neues.**
//
// Ein gewöhnlicher Scan fragt nichts erneut: das ist schneller und
// hält Handarbeit. Wer aber die Sprache von Englisch auf Deutsch
// stellt, bekäme sonst nie deutsche Titel — die Einstellung griffe
// erst beim nächsten neuen Film. Geholt wird über die bekannte
// Kennung, nicht über eine neue Suche; eine Zuordnung von Hand
// bleibt damit stehen.
//
// **Mit einer Auswahl im Rumpf nur diese Titel.** Zehn ausgewählte
// Filme kosten zehn Anfragen bei TMDB; der ganze Bestand viertausend
// und eine Viertelstunde. Ohne Rumpf bleibt es beim ganzen Bestand.
//
// **Der Rumpf wird von Hand gelesen, und das hat einen Grund.** Als
// `RefreshRequest?` im Kopf der Methode lehnte der Server jede Anfrage
// ohne Rumpf mit 404 ab — also genau den Aufruf „die ganze Mediathek
// auffrischen", den es vorher schon gab. Gemessen, nicht vermutet: ohne
// Rumpf 404, mit `{}` 202.
app.MapPost("/api/v1/libraries/{id:guid}/refresh", async (
Guid id, HttpRequest anfrage, FlaroContext db, ScanCoordinator koordinator) =>
{
if (!await db.Libraries.AnyAsync(l => l.Id == id)) return Results.NotFound();
IReadOnlySet<Guid>? nurDiese = null;
if (await Auswahl(anfrage) is { Count: > 0 } gewaehlt)
{
if (gewaehlt.Count > HöchstensJeZug)
return Results.Problem(
$"Mehr als {HöchstensJeZug} Titel auf einmal gehen nicht. "
+ "Nimm weniger und wiederhole es.",
statusCode: StatusCodes.Status400BadRequest);
nurDiese = gewaehlt.ToHashSet();
}
return koordinator.TryStart(id, erneut: true, nurDiese: nurDiese)
? Results.AcceptedAtRoute("Scanstand", new { id })
: Results.Problem(
"Für diese Bibliothek läuft bereits ein Lauf.",
statusCode: StatusCodes.Status409Conflict);
}).RequireAuthorization(FlaroPolicies.Administrator);
app.MapGet("/api/v1/libraries/{id:guid}/scan", (Guid id, ScanCoordinator koordinator) =>
{
var zustand = koordinator.State(id);
if (zustand is null)
return Results.Ok(new ScanStatus("idle", null, null, null, null, null, null, null, null, null, null, null));
var bericht = zustand.Report;
var metadaten = zustand.Enrichment;
return Results.Ok(new ScanStatus(
zustand.Status, zustand.StartedAt, zustand.FinishedAt,
bericht?.FilesFound, bericht?.Added, bericht?.Updated, bericht?.Removed, bericht?.Unrecognised,
bericht?.Probed,
bericht?.Problems.Select(p => new ScanProblem(p.Path, p.Reason)).ToList(),
zustand.Error,
metadaten is null ? null : new MetadataProgress(
metadaten.MoviesMatched, metadaten.SeriesMatched, metadaten.EpisodesMatched,
metadaten.Skipped, metadaten.Error),
bericht?.Missing,
zustand.Schritt?.Text()));
}).WithName("Scanstand").RequireAuthorization();
}
/// <summary>Ähnliche Titel — aus dem eigenen Bestand, nicht aus dem Netz.</summary>
///
/// <remarks>
/// <para>
/// <b>Nur, was man besitzt.</b> Ein Vorschlag, den man nicht abspielen
/// kann, ist Werbung. TMDB hätte eine Empfehlungsliste, aber die besteht
/// zur Hälfte aus Filmen, die hier nicht liegen.
/// </para>
/// <para>
/// <b>Aus Genres und Menschen, ohne eine einzige fremde Anfrage.</b> Der
/// Katalog steht ohnehin in der Datenbank. Ein gemeinsamer Mensch wiegt
/// schwerer als ein gemeinsames Genre: dass zwei Filme „Drama" sind, sagt
/// wenig; dass beide von derselben Regie stammen, sagt viel.
/// </para>
/// </remarks>
private static async Task<List<Guid>> AehnlicheAsync(
FlaroContext db, Guid filmId, int wieViele)
{
var genres = await db.GenreLinks.Where(g => g.MovieId == filmId)
.Select(g => g.GenreId).ToListAsync();
var leute = await db.Credits.Where(c => c.MovieId == filmId)
.Select(c => c.PersonId).ToListAsync();
if (genres.Count == 0 && leute.Count == 0) return [];
var bibliothek = await db.Movies.Where(m => m.Id == filmId)
.Select(m => m.LibraryId).FirstOrDefaultAsync();
var punkte = await db.Movies
.Where(m => m.Id != filmId && m.LibraryId == bibliothek)
.Select(m => new
{
m.Id,
Punkte =
db.GenreLinks.Count(g => g.MovieId == m.Id && genres.Contains(g.GenreId))
+ db.Credits.Count(c => c.MovieId == m.Id && leute.Contains(c.PersonId)) * 3,
})
.Where(x => x.Punkte > 0)
.OrderByDescending(x => x.Punkte)
.Take(wieViele)
.Select(x => x.Id)
.ToListAsync();
return punkte;
}
/// <summary>Die Sprache dieses Benutzers — oder <c>null</c> für die des Servers.</summary>
///
/// <remarks>
/// <b>Einmal je Anfrage, nicht je Zeile.</b> Ein Abruf für fünfzig Titel
/// wäre fünfzig Abfragen für eine Angabe, die sich dabei nicht ändert.
/// </remarks>
private static async Task<string?> SpracheVon(ClaimsPrincipal wer, FlaroContext db)
{
if (PlaybackEndpoints.Benutzer(wer) is not { } kennung) return null;
return await db.Users.Where(u => u.Id == kennung).Select(u => u.Language).FirstOrDefaultAsync();
}
private static void MapBrowse(IEndpointRouteBuilder app)
{
app.MapGet("/api/v1/movies", async (
ClaimsPrincipal wer, FlaroContext db,
Guid? library, string? search, string? sort, string? genre, int skip = 0, int take = 50) =>
{
var sprache = await SpracheVon(wer, db);
var abfrage = db.Movies.AsNoTracking();
if (library is { } kennung) abfrage = abfrage.Where(m => m.LibraryId == kennung);
// **Über beide Titel.** Wer „Despicable Me" tippt, soll „Ich einfach
// unverbesserlich" finden — und umgekehrt. Nur über den
// angezeigten zu suchen hiesse, die halbe Mediathek unauffindbar
// zu machen, sobald jemand den anderen Namen im Kopf hat.
if (!string.IsNullOrWhiteSpace(search))
{
var muster = $"%{search.Trim()}%";
abfrage = abfrage.Where(m =>
EF.Functions.Like(m.Title, muster)
|| (m.OriginalTitle != null && EF.Functions.Like(m.OriginalTitle, muster)));
}
if (!string.IsNullOrWhiteSpace(genre))
{
var gesucht = genre.Trim();
abfrage = abfrage.Where(m => db.GenreLinks
.Any(l => l.MovieId == m.Id && db.Genres.Any(g => g.Id == l.GenreId && g.Name == gesucht)));
}
// Der Titel immer als letzter Vergleich: zwei Filme aus demselben
// Jahr hätten sonst keine feste Reihenfolge, und eine Seite 2
// zeigte dann denselben Film noch einmal oder gar nicht.
abfrage = Ordnung(sort) switch
{
Reihenfolge.Added => abfrage.OrderByDescending(m => m.AddedAt).ThenBy(m => m.SortTitleValue),
Reihenfolge.Year => abfrage.OrderByDescending(m => m.Year).ThenBy(m => m.SortTitleValue),
_ => abfrage.OrderBy(m => m.SortTitleValue).ThenBy(m => m.Year),
};
var gesamt = await abfrage.CountAsync();
var kennungen = await abfrage
.Skip(Math.Max(0, skip)).Take(Math.Clamp(take, 1, HöchstensJeSeite))
.Select(m => new
{
m.Id,
// **Die Übersetzung sticht, wenn es eine gibt.** Fehlt sie,
// steht der Titel in der Sprache des Servers da — nicht
// leer. Ein Film ohne englische Übersetzung heisst dann
// eben weiter „Das Boot", und das ist richtiger als eine
// Lücke.
Title = sprache == null ? m.Title
: db.Translations
.Where(t => t.MovieId == m.Id && t.Language == sprache)
.Select(t => t.Title).FirstOrDefault() ?? m.Title,
m.OriginalTitle, m.Year,
Overview = sprache == null ? m.Overview
: db.Translations
.Where(t => t.MovieId == m.Id && t.Language == sprache)
.Select(t => t.Overview).FirstOrDefault() ?? m.Overview,
m.PosterImage, m.BackdropImage,
m.LogoImage, m.RuntimeMinutes, m.Rating, m.ContentRating,
m.PublishAt,
})
.ToListAsync();
// Dateien in **einer** Abfrage nachholen statt je Film einer.
// Andernfalls kostet eine Seite mit fünfzig Titeln einundfünfzig
// Abfragen, und SQLite führt die nacheinander aus.
var ids = kennungen.Select(m => m.Id).ToList();
var dateien = await db.MediaFiles.AsNoTracking()
.Where(f => f.MovieId != null && ids.Contains(f.MovieId!.Value))
.Select(f => new { f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, f.MovieId, f.VersionLabel })
.ToListAsync();
var spuren = await SpurenZu(db, dateien.Select(f => f.Id).ToList());
var stände = await ZuständeZu(db, wer, filme: ids);
var items = kennungen.Select(m => new MovieResponse(
m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage,
dateien.Where(f => f.MovieId == m.Id)
.Select(f => Datei(f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, spuren, f.VersionLabel)).ToList(),
stände.GetValueOrDefault(m.Id),
m.LogoImage, m.RuntimeMinutes, m.Rating, null, m.ContentRating,
OriginalTitle: m.OriginalTitle,
PublishAt: m.PublishAt)).ToList();
return Results.Ok(new Page<MovieResponse>(items, gesamt));
}).RequireAuthorization();
app.MapGet("/api/v1/series", async (
ClaimsPrincipal wer, FlaroContext db,
Guid? library, string? search, string? sort, string? genre, int skip = 0, int take = 50) =>
{
var sprache = await SpracheVon(wer, db);
var abfrage = db.Series.AsNoTracking();
if (library is { } kennung) abfrage = abfrage.Where(s => s.LibraryId == kennung);
if (!string.IsNullOrWhiteSpace(search))
{
var muster = $"%{search.Trim()}%";
abfrage = abfrage.Where(x =>
EF.Functions.Like(x.Title, muster)
|| (x.OriginalTitle != null && EF.Functions.Like(x.OriginalTitle, muster)));
}
if (!string.IsNullOrWhiteSpace(genre))
{
var gesucht = genre.Trim();
abfrage = abfrage.Where(x => db.GenreLinks
.Any(l => l.SeriesId == x.Id && db.Genres.Any(g => g.Id == l.GenreId && g.Name == gesucht)));
}
abfrage = Ordnung(sort) switch
{
Reihenfolge.Added => abfrage.OrderByDescending(s => s.AddedAt).ThenBy(s => s.SortTitleValue),
Reihenfolge.Year => abfrage.OrderByDescending(s => s.Year).ThenBy(s => s.SortTitleValue),
_ => abfrage.OrderBy(s => s.SortTitleValue).ThenBy(s => s.Year),
};
var gesamt = await abfrage.CountAsync();
var serien = await abfrage
.Skip(Math.Max(0, skip)).Take(Math.Clamp(take, 1, HöchstensJeSeite))
.Select(s => new
{
s.Id,
Title = sprache == null ? s.Title
: db.Translations
.Where(t => t.SeriesId == s.Id && t.Language == sprache)
.Select(t => t.Title).FirstOrDefault() ?? s.Title,
s.OriginalTitle, s.Year,
Overview = sprache == null ? s.Overview
: db.Translations
.Where(t => t.SeriesId == s.Id && t.Language == sprache)
.Select(t => t.Overview).FirstOrDefault() ?? s.Overview,
s.PosterImage, s.BackdropImage,
s.LogoImage, s.RuntimeMinutes, s.Rating, s.ContentRating,
// **Eine Serie wartet, solange keine Folge sichtbar ist.**
// Der früheste Zeitpunkt sagt, ab wann überhaupt etwas da
// ist; steht dort nichts, wartet alles.
PublishAt = db.Episodes.Where(f => f.SeriesId == s.Id)
.Min(f => f.PublishAt),
})
.ToListAsync();
var ids = serien.Select(s => s.Id).ToList();
var folgen = await db.Episodes.AsNoTracking()
.Where(f => ids.Contains(f.SeriesId))
.Select(f => new { f.Id, f.SeriesId, f.SeasonNumber })
.ToListAsync();
// Gesehene Folgen in **einer** Abfrage, für alle Serien der Seite
// zusammen. Je Serie zu fragen wäre bei fünfzig Serien
// einundfünfzig Abfragen.
var gesehen = await GeseheneFolgen(db, wer, folgen.Select(f => f.Id).ToList());
var stände = await ZuständeZu(db, wer, serien: ids);
var items = serien.Select(s =>
{
var meine = folgen.Where(f => f.SeriesId == s.Id).ToList();
return new SeriesResponse(
s.Id, s.Title, s.Year, s.Overview, s.PosterImage, s.BackdropImage, meine.Count,
meine.Select(f => f.SeasonNumber).Distinct().Order().ToList(),
meine.Count(f => !gesehen.Contains(f.Id)),
s.LogoImage, s.RuntimeMinutes, s.Rating, null, s.ContentRating,
OriginalTitle: s.OriginalTitle,
PublishAt: s.PublishAt,
State: stände.GetValueOrDefault(s.Id));
}).ToList();
return Results.Ok(new Page<SeriesResponse>(items, gesamt));
}).RequireAuthorization();
// **Nur die Genres, die in dieser Mediathek auch vorkommen.**
//
// Alle 19 Genres von TMDB anzubieten hiesse: ein Filter, hinter dem
// zu zwei Dritteln nichts steht. Ein Knopf, der zu einer leeren Liste
// führt, ist schlimmer als kein Knopf.
app.MapGet("/api/v1/libraries/{id:guid}/genres", async (Guid id, FlaroContext db) =>
{
var bibliothek = await db.Libraries.AsNoTracking().FirstOrDefaultAsync(l => l.Id == id);
if (bibliothek is null) return Results.NotFound();
var namen = bibliothek.Kind == Core.Libraries.LibraryKind.Shows
? await db.GenreLinks.AsNoTracking()
.Where(l => l.SeriesId != null
&& db.Series.Any(x => x.Id == l.SeriesId && x.LibraryId == id))
.Join(db.Genres.AsNoTracking(), l => l.GenreId, g => g.Id, (_, g) => g.Name)
.Distinct().OrderBy(n => n).ToListAsync()
: await db.GenreLinks.AsNoTracking()
.Where(l => l.MovieId != null
&& db.Movies.Any(m => m.Id == l.MovieId && m.LibraryId == id))
.Join(db.Genres.AsNoTracking(), l => l.GenreId, g => g.Id, (_, g) => g.Name)
.Distinct().OrderBy(n => n).ToListAsync();
return Results.Ok(namen);
}).RequireAuthorization();
// **Einzelabruf, weil ein Client nicht immer aus einer Liste kommt.**
// Ein Link, ein Wiederaufnehmen nach Neustart, ein Eintrag aus
// „Weiterschauen" — in all diesen Fällen hat er nur die Kennung.
// Ähnliche Titel als eigener Abruf: die Detailseite soll stehen,
// bevor diese Rechnung durch ist.
app.MapGet("/api/v1/movies/{id:guid}/similar", async (
Guid id, ClaimsPrincipal wer, FlaroContext db, int take = 12) =>
{
var kennungen = await AehnlicheAsync(db, id, Math.Clamp(take, 1, 24));
if (kennungen.Count == 0) return Results.Ok(new Page<MovieResponse>([], 0));
var sprache = await SpracheVon(wer, db);
var filme = await db.Movies.AsNoTracking()
.Where(m => kennungen.Contains(m.Id))
.Select(m => new
{
m.Id,
Title = sprache == null ? m.Title
: db.Translations.Where(t => t.MovieId == m.Id && t.Language == sprache)
.Select(t => t.Title).FirstOrDefault() ?? m.Title,
m.OriginalTitle, m.Year, m.PosterImage, m.BackdropImage, m.LogoImage,
m.RuntimeMinutes, m.Rating, m.ContentRating,
})
.ToListAsync();
// In der Reihenfolge der Punkte, nicht in der der Datenbank.
var items = kennungen
.Select(k => filme.FirstOrDefault(f => f.Id == k))
.Where(f => f != null)
.Select(f => new MovieResponse(
f!.Id, f.Title, f.Year, null, f.PosterImage, f.BackdropImage,
[], null, f.LogoImage, f.RuntimeMinutes, f.Rating, null, f.ContentRating,
OriginalTitle: f.OriginalTitle))
.ToList();
return Results.Ok(new Page<MovieResponse>(items, items.Count));
}).RequireAuthorization();
app.MapGet("/api/v1/movies/{id:guid}", async (
Guid id, ClaimsPrincipal wer, FlaroContext db) =>
{
var film = await db.Movies.AsNoTracking()
.Where(m => m.Id == id)
.FirstOrDefaultAsync();
if (film is null) return Results.NotFound();
var dateien = await db.MediaFiles.AsNoTracking()
.Where(f => f.MovieId == id)
.Select(f => new { f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, f.VersionLabel })
.ToListAsync();
var spuren = await SpurenZu(db, dateien.Select(f => f.Id).ToList());
var stände = await ZuständeZu(db, wer, filme: [id]);
var sprache = await SpracheVon(wer, db);
var uebersetzt = sprache == null ? null : await db.Translations.AsNoTracking()
.FirstOrDefaultAsync(t => t.MovieId == id && t.Language == sprache);
return Results.Ok(new MovieResponse(
film.Id, uebersetzt?.Title ?? film.Title, film.Year,
uebersetzt?.Overview ?? film.Overview, film.PosterImage, film.BackdropImage,
dateien.Select(f => Datei(f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, spuren, f.VersionLabel)).ToList(),
stände.GetValueOrDefault(id),
film.LogoImage, film.RuntimeMinutes, film.Rating,
uebersetzt?.Tagline ?? film.Tagline, film.ContentRating,
await GenresZu(db, film: id, serie: null),
await MitwirkendeZu(db, film: id, serie: null),
OriginalTitle: film.OriginalTitle));
}).RequireAuthorization();
app.MapGet("/api/v1/series/{id:guid}", async (
Guid id, ClaimsPrincipal wer, FlaroContext db) =>
{
var serie = await db.Series.AsNoTracking()
.Where(x => x.Id == id)
.FirstOrDefaultAsync();
if (serie is null) return Results.NotFound();
var folgen = await db.Episodes.AsNoTracking()
.Where(f => f.SeriesId == id)
.Select(f => new { f.Id, f.SeasonNumber })
.ToListAsync();
var gesehen = await GeseheneFolgen(db, wer, folgen.Select(f => f.Id).ToList());
var sprache = await SpracheVon(wer, db);
var uebersetzt = sprache == null ? null : await db.Translations.AsNoTracking()
.FirstOrDefaultAsync(t => t.SeriesId == id && t.Language == sprache);
return Results.Ok(new SeriesResponse(
serie.Id, uebersetzt?.Title ?? serie.Title, serie.Year,
uebersetzt?.Overview ?? serie.Overview, serie.PosterImage, serie.BackdropImage,
folgen.Count, folgen.Select(f => f.SeasonNumber).Distinct().Order().ToList(),
folgen.Count(f => !gesehen.Contains(f.Id)),
serie.LogoImage, serie.RuntimeMinutes, serie.Rating,
uebersetzt?.Tagline ?? serie.Tagline, serie.ContentRating,
await GenresZu(db, film: null, serie: id),
await MitwirkendeZu(db, film: null, serie: id),
OriginalTitle: serie.OriginalTitle,
State: (await ZuständeZu(db, wer, serien: [id])).GetValueOrDefault(id)));
}).RequireAuthorization();
// **Die nächste Folge nach dieser.** Reihenfolge ist Staffel, dann
// Folge — nicht die Kennung: die ist zeitlich geordnet und sagt, wann
// der Scan sie fand, nicht wann sie läuft.
app.MapGet("/api/v1/episodes/{id:guid}/next", async (
Guid id, ClaimsPrincipal wer, FlaroContext db) =>
{
var diese = await db.Episodes.AsNoTracking()
.Where(f => f.Id == id)
.Select(f => new { f.SeriesId, f.SeasonNumber, f.EpisodeNumber })
.FirstOrDefaultAsync();
if (diese is null) return Results.NotFound();
var naechste = await db.Episodes.AsNoTracking()
.Where(f => f.SeriesId == diese.SeriesId)
.Where(f => f.SeasonNumber > diese.SeasonNumber
|| (f.SeasonNumber == diese.SeasonNumber && f.EpisodeNumber > diese.EpisodeNumber))
.OrderBy(f => f.SeasonNumber).ThenBy(f => f.EpisodeNumber)
.Select(f => new { f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview, f.StillImage })
.FirstOrDefaultAsync();
// Keine weitere Folge ist kein Fehler — es war die letzte.
if (naechste is null) return Results.NoContent();
var dateien = await db.MediaFiles.AsNoTracking()
.Where(d => d.EpisodeId == naechste.Id)
.Select(d => new { d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, d.VersionLabel })
.ToListAsync();
var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList());
var stände = await ZuständeZu(db, wer, folgen: [naechste.Id]);
// **Ohne eigenes Bild das der Serie.** Eine graue Fläche sagt
// nichts; das Plakat der Serie sagt wenigstens, worum es geht.
// TMDB liefert Folgenbilder nur für einen Teil der Folgen, und
// bei kleineren Serien für gar keine.
var serienBild = await db.Series.AsNoTracking()
.Where(x => x.Id == diese.SeriesId)
.Select(x => x.PosterImage).FirstOrDefaultAsync();
return Results.Ok(new EpisodeResponse(
naechste.Id, naechste.SeasonNumber, naechste.EpisodeNumber,
naechste.Title, naechste.Overview, naechste.StillImage ?? serienBild,
dateien.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren, d.VersionLabel)).ToList(),
stände.GetValueOrDefault(naechste.Id)));
}).RequireAuthorization();
app.MapGet("/api/v1/series/{id:guid}/episodes", async (
Guid id, ClaimsPrincipal wer, FlaroContext db, int? season) =>
{
if (!await db.Series.AnyAsync(s => s.Id == id)) return Results.NotFound();
var abfrage = db.Episodes.AsNoTracking().Where(f => f.SeriesId == id);
if (season is { } staffel) abfrage = abfrage.Where(f => f.SeasonNumber == staffel);
var folgen = await abfrage
.OrderBy(f => f.SeasonNumber).ThenBy(f => f.EpisodeNumber)
.Select(f => new { f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview, f.StillImage })
.ToListAsync();
var ids = folgen.Select(f => f.Id).ToList();
var dateien = await db.MediaFiles.AsNoTracking()
.Where(d => d.EpisodeId != null && ids.Contains(d.EpisodeId!.Value))
.Select(d => new { d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, d.EpisodeId, d.VersionLabel })
.ToListAsync();
var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList());
var stände = await ZuständeZu(db, wer, folgen: ids);
var serienBild = await db.Series.AsNoTracking()
.Where(x => x.Id == id)
.Select(x => x.PosterImage).FirstOrDefaultAsync();
var items = folgen.Select(f => new EpisodeResponse(
f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview,
f.StillImage ?? serienBild,
dateien.Where(d => d.EpisodeId == f.Id)
.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren, d.VersionLabel)).ToList(),
stände.GetValueOrDefault(f.Id))).ToList();
return Results.Ok(new Page<EpisodeResponse>(items, items.Count));
}).RequireAuthorization();
}
}