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
{
/// Mehr als das liefert keine Anfrage — auch wenn sie danach fragt.
///
///
/// Ohne Obergrenze holt ein ?take=100000 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.
///
private const int HöchstensJeSeite = 200;
/// Wie viele Titel eine Auswahl höchstens umfasst.
private const int HöchstensJeZug = 1000;
/// Welche Titel im Rumpf stehen — oder null, wenn keiner da ist.
///
///
/// Ein fehlender Rumpf ist kein Fehler, sondern die Voreinstellung.
/// „Alles auffrischen" schickt nichts mit, und das muss so bleiben: der
/// Knopf dafür gibt es länger als die Auswahl.
///
private static async Task?> Auswahl(HttpRequest anfrage)
{
if (anfrage.ContentLength is null or 0) return null;
if (!anfrage.HasJsonContentType()) return null;
try
{
var gelesen = await anfrage.ReadFromJsonAsync();
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;
}
}
/// Wonach eine Liste geordnet wird.
///
///
/// Als Wort, nicht als Spaltenname. „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.
///
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);
}
/// Holt die Spuren aller genannten Dateien in einer Abfrage.
///
///
/// Je Datei einzeln zu fragen hiesse: fünfzig Titel, fünfzig Abfragen —
/// und SQLite führt die nacheinander aus.
///
private static async Task> SpurenZu(FlaroContext db, List 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 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));
/// Die Genres eines Titels, in der Reihenfolge der Quelle.
private static async Task> 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();
/// Besetzung und Stab — Darsteller zuerst, in ihrer Reihenfolge.
private static async Task> 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();
/// Die Stände dieses Benutzers zu den genannten Titeln.
///
///
/// 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 null.
///
private static async Task> ZuständeZu(
FlaroContext db, ClaimsPrincipal wer,
List? filme = null, List? folgen = null, List? 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);
}
/// Welche der genannten Folgen dieser Benutzer gesehen hat.
private static async Task> GeseheneFolgen(
FlaroContext db, ClaimsPrincipal wer, List 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? 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();
}
/// Ähnliche Titel — aus dem eigenen Bestand, nicht aus dem Netz.
///
///
///
/// Nur, was man besitzt. 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.
///
///
/// Aus Genres und Menschen, ohne eine einzige fremde Anfrage. 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.
///
///
private static async Task> 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;
}
/// Die Sprache dieses Benutzers — oder null für die des Servers.
///
///
/// Einmal je Anfrage, nicht je Zeile. Ein Abruf für fünfzig Titel
/// wäre fünfzig Abfragen für eine Angabe, die sich dabei nicht ändert.
///
private static async Task 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(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(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([], 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(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(items, items.Count));
}).RequireAuthorization();
}
}