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(); } }