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; /// 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)); } private static MediaFileResponse Datei( Guid id, string pfad, long bytes, string? schachtel, TimeSpan? dauer, ILookup spuren) => new(id, System.IO.Path.GetFileName(pfad), bytes, schachtel, dauer?.TotalSeconds, spuren[id].ToList()); /// 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) { if (PlaybackEndpoints.Benutzer(wer) is not { } benutzer) return []; var gesucht = (filme ?? folgen)!; 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) : z.EpisodeId != null && gesucht.Contains(z.EpisodeId!.Value)) .ToListAsync(); return zeilen.ToDictionary( z => (filme != null ? z.MovieId : z.EpisodeId)!.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. app.MapPost("/api/v1/libraries/{id:guid}/refresh", async ( Guid id, FlaroContext db, ScanCoordinator koordinator) => { if (!await db.Libraries.AnyAsync(l => l.Id == id)) return Results.NotFound(); return koordinator.TryStart(id, erneut: true) ? 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))); }).WithName("Scanstand").RequireAuthorization(); } 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 abfrage = db.Movies.AsNoTracking(); if (library is { } kennung) abfrage = abfrage.Where(m => m.LibraryId == kennung); if (!string.IsNullOrWhiteSpace(search)) abfrage = abfrage.Where(m => EF.Functions.Like(m.Title, $"%{search.Trim()}%")); 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, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage, m.LogoImage, m.RuntimeMinutes, m.Rating, m.ContentRating, }) .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 }) .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)).ToList(), stände.GetValueOrDefault(m.Id), m.LogoImage, m.RuntimeMinutes, m.Rating, null, m.ContentRating)).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 abfrage = db.Series.AsNoTracking(); if (library is { } kennung) abfrage = abfrage.Where(s => s.LibraryId == kennung); if (!string.IsNullOrWhiteSpace(search)) abfrage = abfrage.Where(s => EF.Functions.Like(s.Title, $"%{search.Trim()}%")); 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, s.Title, s.Year, s.Overview, s.PosterImage, s.BackdropImage, s.LogoImage, s.RuntimeMinutes, s.Rating, s.ContentRating, }) .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 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); }).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. 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 }) .ToListAsync(); var spuren = await SpurenZu(db, dateien.Select(f => f.Id).ToList()); var stände = await ZuständeZu(db, wer, filme: [id]); return Results.Ok(new MovieResponse( film.Id, film.Title, film.Year, film.Overview, film.PosterImage, film.BackdropImage, dateien.Select(f => Datei(f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, spuren)).ToList(), stände.GetValueOrDefault(id), film.LogoImage, film.RuntimeMinutes, film.Rating, film.Tagline, film.ContentRating, await GenresZu(db, film: id, serie: null), await MitwirkendeZu(db, film: id, serie: null))); }).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()); return Results.Ok(new SeriesResponse( serie.Id, serie.Title, serie.Year, 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, serie.Tagline, serie.ContentRating, await GenresZu(db, film: null, serie: id), await MitwirkendeZu(db, film: null, serie: 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 }) .ToListAsync(); var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList()); var stände = await ZuständeZu(db, wer, folgen: [naechste.Id]); return Results.Ok(new EpisodeResponse( naechste.Id, naechste.SeasonNumber, naechste.EpisodeNumber, naechste.Title, naechste.Overview, naechste.StillImage, dateien.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren)).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 }) .ToListAsync(); var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList()); var stände = await ZuständeZu(db, wer, folgen: ids); var items = folgen.Select(f => new EpisodeResponse( f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview, f.StillImage, dateien.Where(d => d.EpisodeId == f.Id) .Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren)).ToList(), stände.GetValueOrDefault(f.Id))).ToList(); return Results.Ok(new Page(items, items.Count)); }).RequireAuthorization(); } }