Files
Flaro/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs
T
ScarriffleandClaude Opus 5 acba683c94 Eine Folge weiter, eine zurück — ohne den Spieler zu verlassen
Wer eine Serie schaut, will blättern können. Bisher gab es genau einen
Knopf dafür, und der erschien erst am Ende: „Nächste Folge", wenn der
Abspann durchgelaufen war. Zurück ging gar nicht — nur über die
Serienseite, und die beendet die Wiedergabe.

Jetzt stehen beide Knöpfe in der Bedienleiste, in der App wie im Web,
links und rechts vom Abspielknopf. Nur bei Serien: bei einem Film
antwortet der Server mit 404, bei der letzten Folge mit 204, und beides
heisst hier „kein Knopf". Sie erscheinen also von selbst nur dort, wo
sie etwas tun.

Der Server kannte nur den Weg vorwärts. Jetzt gibt es `/previous`
daneben, und beide Richtungen kommen aus derselben Rechnung — als zwei
Endpunkte abgeschrieben liefen sie auseinander, sobald einer etwas
dazubekommt. Jellyfin hat kein Gegenstück zu „NextUp"; dort entsteht
die vorige Folge aus der Folgenliste der Serie.

Beim Wechsel gilt weiter, was für die Serie an Sprache eingestellt ist.
Ohne das spränge die nächste Folge auf die erste Tonspur der Datei
zurück — bei deutschen Veröffentlichungen also auf Deutsch, mitten in
einer Serie, die man im Original schaut.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-12 21:11:09 +02:00

922 lines
45 KiB
C#

using Flaro.Api.Auth;
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>
/// <summary>Die Folge vor oder nach dieser.</summary>
///
/// <remarks>
/// <b>Eine Rechnung, zwei Wege.</b> Vorwärts und rückwärts unterscheiden
/// sich in zwei Zeichen — als zwei Endpunkte abgeschrieben liefen sie
/// auseinander, sobald einer von beiden etwas dazubekommt.
/// </remarks>
private static async Task<IResult> Nachbarfolge(
Guid id, ClaimsPrincipal wer, FlaroContext db, bool vorwaerts)
{
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 geschwister = db.Episodes.AsNoTracking().Where(f => f.SeriesId == diese.SeriesId);
var gefiltert = vorwaerts
? geschwister
.Where(f => f.SeasonNumber > diese.SeasonNumber
|| (f.SeasonNumber == diese.SeasonNumber && f.EpisodeNumber > diese.EpisodeNumber))
.OrderBy(f => f.SeasonNumber).ThenBy(f => f.EpisodeNumber)
: geschwister
.Where(f => f.SeasonNumber < diese.SeasonNumber
|| (f.SeasonNumber == diese.SeasonNumber && f.EpisodeNumber < diese.EpisodeNumber))
.OrderByDescending(f => f.SeasonNumber).ThenByDescending(f => f.EpisodeNumber);
var nachbar = await gefiltert
.Select(f => new { f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview, f.StillImage, f.PublishAt })
.FirstOrDefaultAsync();
// Keine weitere Folge ist kein Fehler — es war die erste oder die letzte.
if (nachbar is null) return Results.NoContent();
var dateien = await db.MediaFiles.AsNoTracking()
.Where(d => d.EpisodeId == nachbar.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: [nachbar.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(
nachbar.Id, nachbar.SeasonNumber, nachbar.EpisodeNumber,
nachbar.Title, nachbar.Overview, nachbar.StillImage ?? serienBild,
dateien.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren, d.VersionLabel)).ToList(),
stände.GetValueOrDefault(nachbar.Id),
nachbar.PublishAt,
SeriesId: diese.SeriesId));
}
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.Width, s.Height),
MediaStreamKind.Audio => Benennung.Tonspur(s.Language, s.Codec, s.Channels, s.Title),
_ => Benennung.Untertitelspur(s.Language, s.IsForced, fuerHoergeschaedigte: false),
},
LanguageName: Benennung.Sprache(s.Language),
ResolutionLabel: s.Kind == MediaStreamKind.Video
? Benennung.Aufloesungswort(s.Width, s.Height) : null,
ResolutionExact: s.Kind == MediaStreamKind.Video
? Benennung.Aufloesung(s.Width, s.Height) : null));
}
private static MediaFileResponse Datei(
Guid id, string pfad, long bytes, string? schachtel, TimeSpan? dauer,
ILookup<Guid, MediaStreamResponse> spuren, string? fassung = null,
string? mediathekspfad = null) =>
new(id, System.IO.Path.GetFileName(pfad), bytes, schachtel, dauer?.TotalSeconds,
spuren[id].ToList(),
// **Drei Quellen, in dieser Reihenfolge.** Was in der Datenbank
// steht, kam aus dem Dateinamen und ist das Genaueste. Sonst wird
// der Pfad gelesen — ein Bestand von vor dieser Änderung soll
// nicht erst neu gescannt werden müssen, um „Mobil" zu zeigen.
// Und sonst die Messung: hier gerechnet und nicht gespeichert,
// damit ein neues Wort nicht erst einen Scan braucht.
fassung ?? FileNames.Fassung(pfad) ?? AusDenSpuren(spuren[id]),
Ordner(pfad, mediathekspfad));
/// <summary>Die Fassung aus der gemessenen Bildgrösse — „Full HD", „SD".</summary>
private static string? AusDenSpuren(IEnumerable<MediaStreamResponse> spuren) =>
spuren.FirstOrDefault(s => s.Kind == nameof(MediaStreamKind.Video))?.ResolutionLabel;
/// <summary>Der Pfad der Mediathek — nur für Administratoren.</summary>
///
/// <remarks>
/// <b>Hier entscheidet sich, wer den Ordner sieht.</b> Ohne Wurzel bleibt
/// <see cref="Ordner"/> bei <c>null</c>, und das Feld kommt erst gar nicht
/// in der Antwort an — statt es im Client zu verstecken, wo es jeder
/// auslesen könnte.
/// </remarks>
private static async Task<string?> MediathekspfadFuer(
FlaroContext db, ClaimsPrincipal wer, Guid bibliothek)
{
if (!wer.IsInRole(FlaroRoles.Administrator)) return null;
return await db.Libraries.AsNoTracking()
.Where(l => l.Id == bibliothek)
.Select(l => l.Path)
.FirstOrDefaultAsync();
}
/// <summary>Der Ordner unterhalb der Mediathek — oder <c>null</c>.</summary>
///
/// <remarks>
/// <b>Nur der Teil unterhalb, nie der volle Pfad.</b> Der verriete jedem
/// Angemeldeten den Aufbau des Servers; was zum Beurteilen einer Zuordnung
/// nötig ist, ist der Ordnername — „Hanni und Nanni" neben dem Titel
/// „Hanni &amp; Nanni 3" sagt in einer Sekunde, dass etwas nicht stimmt.
///
/// <c>null</c> für alle, die nicht verwalten dürfen: dort wird der
/// Mediathekspfad gar nicht erst hereingereicht.
/// </remarks>
private static string? Ordner(string pfad, string? mediathekspfad)
{
if (string.IsNullOrEmpty(mediathekspfad)) return null;
var ordner = System.IO.Path.GetDirectoryName(pfad);
if (string.IsNullOrEmpty(ordner)) return null;
var wurzel = mediathekspfad.TrimEnd('/', '\\');
if (!ordner.StartsWith(wurzel, StringComparison.Ordinal)) return null;
var darunter = ordner[wurzel.Length..].Trim('/', '\\');
// Liegt die Datei unmittelbar in der Mediathek, gibt es keinen Ordner
// — und „" wäre eine Zeile, die nichts sagt.
return darunter.Length > 0 ? darunter : null;
}
/// <summary>Die Genres eines Titels, in der Reihenfolge der Quelle.</summary>
/// <summary>Die Bewertungen von anderswo zu einem Titel.</summary>
///
/// <remarks>
/// <b>Nur beim Einzelabruf.</b> In einer Liste mit fünfzig Kacheln
/// stünden sie nirgends, kosteten aber fünfzig Abfragen — dieselbe
/// Überlegung wie bei Genres und Besetzung.
/// </remarks>
private static async Task<IReadOnlyList<Fremdwertungsantwort>> FremdwertungenZu(
FlaroContext db, Guid? film, Guid? serie) =>
await db.Fremdbewertungen.AsNoTracking()
.Where(b => film != null ? b.MovieId == film : b.SeriesId == serie)
.OrderByDescending(b => b.Wert)
.Select(b => new Fremdwertungsantwort(b.Quelle, b.Wert, b.Anzeige))
.ToListAsync();
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>
/// <remarks>
/// <b>Nicht mehr privat.</b> Drei Listen ausserhalb dieser Klasse gaben
/// `State: null` zurück, obwohl die Stände in derselben Datenbank
/// liegen: Vorschläge, saisonale Reihe und ähnliche Titel. Ein halb
/// gesehener Film stand dort ohne Balken, während dieselbe Kachel in der
/// Mediathek einen hatte.
/// </remarks>
internal 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,
// **Damit die Kachel weiss, wohin sie gehört.** Nach einer
// Korrektur stand „WALL·E" weiter unter E, bis man die
// Seite neu lud.
m.SortTitleValue, m.TitlePinned, m.SortTitlePinned,
})
.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,
TitlePinned: m.TitlePinned,
SortTitle: m.SortTitleValue,
SortTitlePinned: m.SortTitlePinned)).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),
s.SortTitleValue, s.TitlePinned, s.SortTitlePinned,
})
.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),
TitlePinned: s.TitlePinned,
SortTitle: s.SortTitleValue,
SortTitlePinned: s.SortTitlePinned);
}).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();
var ständeÄhnliche = await ZuständeZu(db, wer, filme: kennungen.ToList());
// 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,
[], ständeÄhnliche.GetValueOrDefault(f.Id),
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 wurzel = await MediathekspfadFuer(db, wer, film.LibraryId);
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, wurzel)).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,
TitlePinned: film.TitlePinned,
SortTitle: film.SortTitleValue,
SortTitlePinned: film.SortTitlePinned,
PublishAt: film.PublishAt,
ExternalRatings: await FremdwertungenZu(db, film: id, serie: null),
LibraryId: film.LibraryId));
}).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, f.PublishAt })
.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),
TitlePinned: serie.TitlePinned,
SortTitle: serie.SortTitleValue,
SortTitlePinned: serie.SortTitlePinned,
PublishAt: folgen.Min(f => f.PublishAt),
ExternalRatings: await FremdwertungenZu(db, film: null, serie: id),
LibraryId: serie.LibraryId));
}).RequireAuthorization();
// **Eine Folge für sich.**
//
// Es gab sie nur im Rudel — als Liste einer Serie. Die Apps reichen
// aber eine blanke Kennung durch: sie haben sie aus „Weiterschauen"
// und wissen nicht, ob ein Film, eine Serie oder eine Folge
// dahintersteckt. Sie fragten der Reihe nach Film und Serie, fanden
// beides nicht und meldeten „Dieser Titel ist auf dem Server nicht
// mehr vorhanden" — über eine Folge, die sie selbst gerade zeigten.
app.MapGet("/api/v1/episodes/{id:guid}", async (
Guid id, ClaimsPrincipal wer, FlaroContext db) =>
{
var folge = await db.Episodes.AsNoTracking()
.Where(f => f.Id == id)
.Select(f => new
{
f.Id, f.SeriesId, f.SeasonNumber, f.EpisodeNumber,
f.Title, f.Overview, f.StillImage, f.PublishAt,
})
.FirstOrDefaultAsync();
if (folge is null) return Results.NotFound();
var dateien = await db.MediaFiles.AsNoTracking()
.Where(d => d.EpisodeId == 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: [id]);
var wurzel = await MediathekspfadFuer(db, wer,
await db.Series.AsNoTracking().Where(x => x.Id == folge.SeriesId)
.Select(x => x.LibraryId).FirstAsync());
return Results.Ok(new EpisodeResponse(
folge.Id, folge.SeasonNumber, folge.EpisodeNumber,
folge.Title, folge.Overview, folge.StillImage,
dateien.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren, d.VersionLabel, wurzel)).ToList(),
stände.GetValueOrDefault(id),
folge.PublishAt,
SeriesId: folge.SeriesId));
}).RequireAuthorization();
// **Die Nachbarfolge — vorwärts wie rückwärts.** Reihenfolge ist
// Staffel, dann Folge; nicht die Kennung: die ist zeitlich geordnet
// und sagt, wann der Scan sie fand, nicht wann sie läuft.
//
// **Beide Richtungen, seit es im Spieler Knöpfe dafür gibt.** Wer
// eine Folge zu weit gesprungen ist, soll zurück können, ohne über
// die Serienseite zu gehen.
app.MapGet("/api/v1/episodes/{id:guid}/next", (Guid id, ClaimsPrincipal wer, FlaroContext db) =>
Nachbarfolge(id, wer, db, vorwaerts: true)).RequireAuthorization();
app.MapGet("/api/v1/episodes/{id:guid}/previous", (Guid id, ClaimsPrincipal wer, FlaroContext db) =>
Nachbarfolge(id, wer, db, vorwaerts: false)).RequireAuthorization();
// **Welche Sprachen eine Serie durchgehend hat.**
//
// Für die Vorwahl auf der Serienseite. Die Folgen einzeln abzufragen
// wären bei zwanzig Folgen zwanzig Rundreisen, und am Ende müsste
// der Client dieselbe Rechnung anstellen.
//
// **Die Schnittmenge, nicht die Vereinigung.** Eine Sprache
// anzubieten, die es nur in Staffel 1 gibt, hiesse: eine Einstellung
// für die Serie, die ab Staffel 2 stillschweigend etwas anderes tut.
// Was dabei wegfällt, wird gezählt, damit die Oberfläche es sagen
// kann statt es zu verschweigen.
app.MapGet("/api/v1/series/{id:guid}/languages", async (Guid id, FlaroContext db) =>
{
if (!await db.Series.AnyAsync(x => x.Id == id)) return Results.NotFound();
var folgen = await db.Episodes.AsNoTracking()
.Where(f => f.SeriesId == id).Select(f => f.Id).ToListAsync();
var dateien = await db.MediaFiles.AsNoTracking()
.Where(f => f.EpisodeId != null && folgen.Contains(f.EpisodeId!.Value))
.Select(f => new { f.Id, Folge = f.EpisodeId!.Value })
.ToListAsync();
// **Nur Folgen, die überhaupt eine Datei haben.** Eine Folge, die
// noch nicht da ist, kann keine Sprache beisteuern — sie als
// fehlend zu zählen liesse die Schnittmenge leer werden.
var mitDatei = dateien.Select(d => d.Folge).Distinct().Count();
if (mitDatei == 0) return Results.Ok(new Sprachauswahl([], [], 0));
var zuFolge = dateien.ToDictionary(d => d.Id, d => d.Folge);
var kennungen = dateien.Select(d => d.Id).ToList();
var spuren = await db.MediaStreams.AsNoTracking()
.Where(x => kennungen.Contains(x.MediaFileId))
.Where(x => x.Kind == MediaStreamKind.Audio || x.Kind == MediaStreamKind.Subtitle)
.Select(x => new { x.MediaFileId, x.Kind, x.Language, x.Codec })
.ToListAsync();
var (ton, tonFehlt) = Sprachschnitt.Von(
spuren.Where(x => x.Kind == MediaStreamKind.Audio)
.Select(x => (zuFolge[x.MediaFileId], x.Language)),
mitDatei);
var (text, textFehlt) = Sprachschnitt.Von(
spuren.Where(x => x.Kind == MediaStreamKind.Subtitle)
// Bildbasierte Untertitel lassen sich nicht nach WebVTT
// wandeln; sie anzubieten wäre eine Zusage, die niemand
// einlösen kann.
.Where(x => x.Codec is null || !Untertitelvorrat.IstBild(x.Codec))
.Select(x => (zuFolge[x.MediaFileId], x.Language)),
mitDatei);
return Results.Ok(new Sprachauswahl(
[.. ton.Select(x => new Sprachangabe(x.Code, x.Name))],
[.. text.Select(x => new Sprachangabe(x.Code, x.Name))],
tonFehlt + textFehlt));
}).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, f.PublishAt })
.ToListAsync();
var wurzel = await MediathekspfadFuer(db, wer,
await db.Series.AsNoTracking().Where(x => x.Id == id)
.Select(x => x.LibraryId).FirstAsync());
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, wurzel)).ToList(),
stände.GetValueOrDefault(f.Id),
f.PublishAt,
SeriesId: id)).ToList();
return Results.Ok(new Page<EpisodeResponse>(items, items.Count));
}).RequireAuthorization();
}
}