using System.Globalization;
using System.Text;
using Flaro.Api.Auth;
using Flaro.Core.Library;
using Flaro.Media;
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.
///
/// Die Folge vor oder nach dieser.
///
///
/// Eine Rechnung, zwei Wege. Vorwärts und rückwärts unterscheiden
/// sich in zwei Zeichen — als zwei Endpunkte abgeschrieben liefen sie
/// auseinander, sobald einer von beiden etwas dazubekommt.
///
private static async Task 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]);
var segmente = await SegmenteZu(db, [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,
Markers: segmente[nachbar.Id].ToList()));
}
/// Die Segmente zu einer Reihe von Folgen.
///
///
/// Als Nachschlagewerk und nicht je Folge einzeln: eine Staffel mit
/// zwanzig Folgen wären sonst zwanzig Abfragen für zwanzig kurze Zeilen.
///
private static async Task> SegmenteZu(
FlaroContext db, IReadOnlyCollection folgen)
{
if (folgen.Count == 0)
return Array.Empty<(Guid, MarkerResponse)>().ToLookup(x => x.Item1, x => x.Item2);
var roh = await db.Segmente.AsNoTracking()
.Where(m => m.EpisodeId != null && folgen.Contains(m.EpisodeId!.Value))
// In Zeitfolge: ein geteilter Abspann kommt als zwei Segmente, und
// die Spieler nehmen die erste passende.
.OrderBy(m => m.VonSekunden)
.Select(m => new { Id = m.EpisodeId!.Value, m.Art, m.VonSekunden, m.BisSekunden })
.ToListAsync();
return roh.ToLookup(
m => m.Id,
m => new MarkerResponse(
m.Art == Segmentart.Intro ? "intro" : "credits", m.VonSekunden, m.BisSekunden));
}
/// Dasselbe für Filme.
///
///
/// Getrennt und nicht über eine gemeinsame Kennung: ein Segment gehört
/// entweder zu einer Folge oder zu einem Film, und eine Abfrage, die
/// beides zugleich sucht, fände zu einer Filmkennung auch eine Folge
/// mit derselben — unmöglich heute, aber nichts hält es fest.
///
private static async Task> FilmsegmenteZu(
FlaroContext db, IReadOnlyCollection filme)
{
if (filme.Count == 0)
return Array.Empty<(Guid, MarkerResponse)>().ToLookup(x => x.Item1, x => x.Item2);
var roh = await db.Segmente.AsNoTracking()
.Where(m => m.MovieId != null && filme.Contains(m.MovieId!.Value))
.OrderBy(m => m.VonSekunden)
.Select(m => new { Id = m.MovieId!.Value, m.Art, m.VonSekunden, m.BisSekunden })
.ToListAsync();
return roh.ToLookup(
m => m.Id,
m => new MarkerResponse(
m.Art == Segmentart.Intro ? "intro" : "credits", m.VonSekunden, m.BisSekunden));
}
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.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 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));
/// Die Fassung aus der gemessenen Bildgrösse — „Full HD", „SD".
private static string? AusDenSpuren(IEnumerable spuren) =>
spuren.FirstOrDefault(s => s.Kind == nameof(MediaStreamKind.Video))?.ResolutionLabel;
/// Der Pfad der Mediathek — nur für Administratoren.
///
///
/// Hier entscheidet sich, wer den Ordner sieht. Ohne Wurzel bleibt
/// bei null, und das Feld kommt erst gar nicht
/// in der Antwort an — statt es im Client zu verstecken, wo es jeder
/// auslesen könnte.
///
private static async Task 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();
}
/// Der Ordner unterhalb der Mediathek — oder null.
///
///
/// Nur der Teil unterhalb, nie der volle Pfad. 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 & Nanni 3" sagt in einer Sekunde, dass etwas nicht stimmt.
///
/// null für alle, die nicht verwalten dürfen: dort wird der
/// Mediathekspfad gar nicht erst hereingereicht.
///
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;
}
/// Die Genres eines Titels, in der Reihenfolge der Quelle.
/// Die Bewertungen von anderswo zu einem Titel.
///
///
/// Nur beim Einzelabruf. In einer Liste mit fünfzig Kacheln
/// stünden sie nirgends, kosteten aber fünfzig Abfragen — dieselbe
/// Überlegung wie bei Genres und Besetzung.
///
private static async Task> 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> 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.
///
///
/// Nicht mehr privat. 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.
///
internal 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, Segmenterkennung erkennung) =>
{
// **Auch ohne Scan kann etwas laufen.** Die Segmentsuche lässt
// sich von Hand anstossen; ohne diese Zeile stünde die
// Verwaltung dann auf „idle", während der Server stundenlang
// arbeitet.
var segmente = erkennung.Stand(id)?.Text();
var zustand = koordinator.State(id);
if (zustand is null)
return Results.Ok(new ScanStatus(
segmente is null ? "idle" : "marken",
null, null, null, null, null, null, null, null, null, null, null,
Segmente: segmente));
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(),
segmente));
}).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,
// **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,
m.Aufmacherbilder,
})
.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 segmente = await FilmsegmenteZu(db, 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,
HeroImage: Aufmacherwahl.Jetziges(m.Aufmacherbilder, m.Id, DateTimeOffset.UtcNow),
Markers: segmente[m.Id].ToList()))
.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),
s.SortTitleValue, s.TitlePinned, s.SortTitlePinned,
s.Aufmacherbilder,
})
.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,
HeroImage: Aufmacherwahl.Jetziges(s.Aufmacherbilder, s.Id, DateTimeOffset.UtcNow));
}).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();
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(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,
HeroImage: Aufmacherwahl.Jetziges(film.Aufmacherbilder, id, DateTimeOffset.UtcNow),
Markers: (await FilmsegmenteZu(db, [id]))[id].ToList()));
}).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,
HeroImage: Aufmacherwahl.Jetziges(serie.Aufmacherbilder, id, DateTimeOffset.UtcNow)));
}).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 segmente = await SegmenteZu(db, [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,
Markers: segmente[id].ToList()));
}).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.
// **Vorspann und Abspann suchen — auf Zuruf.**
//
// Der Lauf dauert Minuten: er liest von jeder Folge einer Staffel
// ein Stück Ton und vergleicht die Abdrücke. Deshalb 202 und ein
// Hintergrundlauf statt einer Antwort, auf die jemand wartet.
// Dasselbe für einen Film — dort sucht der Server im Bild.
app.MapPost("/api/v1/movies/{id:guid}/marken", async (
Guid id, FlaroContext db, Segmenterkennung erkennung,
Segmentumfang umfang = Segmentumfang.Alles) =>
{
if (!await db.Movies.AsNoTracking().AnyAsync(x => x.Id == id)) return Results.NotFound();
_ = erkennung.FilmAsync(id, umfang);
return Results.Accepted();
}).RequireAuthorization(FlaroPolicies.Administrator);
// **Die Messkurve eines Films — zum Nachsehen, nicht zum Erkennen.**
//
// Die Erkennung sagt „kein Abspann" und nennt die Gründe; was sie
// nicht sagen kann, ist, wie die Kurve aussieht. Genau die braucht
// es, bevor eine neue Schwelle gesetzt wird — zum Beispiel für die
// Frage, ob bei Outtakes im Abspann wenigstens die Ränder dunkel
// bleiben.
app.MapGet("/api/v1/movies/{id:guid}/markenkurve", async (
Guid id, FlaroContext db, Frameabtaster abtaster, CancellationToken ct) =>
{
var film = await db.Movies.AsNoTracking()
.Where(m => m.Id == id)
.Join(db.MediaFiles.AsNoTracking(), m => m.Id, d => d.MovieId,
(m, d) => new { m.Title, d.Path, d.Duration })
.FirstOrDefaultAsync(ct);
if (film?.Duration is not { TotalSeconds: > 0 } laufzeit)
return Einwand.Bitte("Zu diesem Film gibt es keine Datei mit Laufzeit.");
var ab = Math.Max(0, laufzeit.TotalSeconds - Segmenterkennung.Suchbereich);
var kurve = await abtaster.KurveAsync(film.Path, ab, laufzeit.TotalSeconds - ab, ct);
var text = new StringBuilder("sekunde;gesamt;links;rechts\n");
foreach (var punkt in kurve)
{
text.Append(CultureInfo.InvariantCulture,
$"{punkt.Sekunde:0.###};{punkt.Gesamt:0.#};{punkt.Links:0.#};{punkt.Rechts:0.#}\n");
}
var name = string.Concat((film.Title ?? "film")
.Select(c => char.IsLetterOrDigit(c) ? c : '-'));
return Results.File(Encoding.UTF8.GetBytes(text.ToString()),
"text/csv", $"markenkurve-{name}.csv");
}).RequireAuthorization(FlaroPolicies.Administrator);
// **Ein Segment wieder wegnehmen.**
//
// Ohne diesen Weg wäre die Erkennung eine Blackbox, die manchmal
// recht hat: ein Fehlgriff bliebe stehen, und niemand könnte etwas
// dagegen tun ausser die Datenbank anzufassen.
app.MapDelete("/api/v1/movies/{id:guid}/marken", async (Guid id, FlaroContext db) =>
{
var weg = await db.Segmente.Where(m => m.MovieId == id).ExecuteDeleteAsync();
// Der Vermerk bleibt: „gesucht" stimmt weiterhin, und ohne ihn
// legte der nächste Scan dieselbe falsches Segment wieder an.
return weg > 0 ? Results.NoContent() : Results.NotFound();
}).RequireAuthorization(FlaroPolicies.Administrator);
// **Bei einem einzelnen Titel ist „alles" die Vorgabe.** Wer den
// Knopf bei genau dieser Serie drückt, ist mit genau dieser Serie
// unzufrieden; ihn dann nach Lücken suchen zu lassen hiesse, ihm
// die Antwort zu verweigern, die er haben will.
app.MapPost("/api/v1/series/{id:guid}/marken", async (
Guid id, FlaroContext db, Segmenterkennung erkennung,
Segmentumfang umfang = Segmentumfang.Alles) =>
{
if (!await db.Series.AsNoTracking().AnyAsync(x => x.Id == id)) return Results.NotFound();
return erkennung.Starten(id, umfang)
? Results.Accepted()
: Einwand.Belegt("Für diese Serie werden gerade Segmente gesucht.");
}).RequireAuthorization(FlaroPolicies.Administrator);
// **Eine ganze Mediathek auf einmal.** Je Serie einen Knopf zu
// drücken ist bei fünfundneunzig Serien kein Angebot — und nach
// jeder Änderung an der Erkennung will man ohnehin alles noch
// einmal. Der Lauf kann Stunden dauern und läuft deshalb im
// Hintergrund weiter; die Antwort kommt sofort.
app.MapPost("/api/v1/libraries/{id:guid}/marken", async (
Guid id, FlaroContext db, Segmenterkennung erkennung,
Segmentumfang umfang = Segmentumfang.Neue) =>
{
if (!await db.Libraries.AsNoTracking().AnyAsync(x => x.Id == id))
return Results.NotFound();
erkennung.MediathekStarten(id, umfang);
return Results.Accepted();
}).RequireAuthorization(FlaroPolicies.Administrator);
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 segmente = await SegmenteZu(db, 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,
Markers: segmente[f.Id].ToList())).ToList();
return Results.Ok(new Page(items, items.Count));
}).RequireAuthorization();
}
}