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