Bisher standen zu einem Film vier Angaben in der Datenbank: Titel, Jahr, Beschreibung, zwei Bilder. Damit lässt sich eine Kachel zeichnen und sonst nichts — keine Detailseite, die mehr sagt als der Dateiname, keine Vorschläge, kein Filter nach Genre. Alles drei hing daran. Jetzt kommen dazu: **Laufzeit, Bewertung, Werbezeile, Altersfreigabe, Genres, Besetzung, Stab — und der Schriftzug des Titels.** Letzterer ist das, was eine Startseite teuer aussehen lässt: „Blade Runner 2049" in seiner eigenen Schrift über dem Hintergrundbild statt in der Systemschrift. **Alles in einer Anfrage.** `append_to_response` hängt Besetzung, Fremdkennungen, Freigaben und Bilder an den Einzelabruf. Vier getrennte Anfragen je Titel wären bei vierhundert Filmen sechzehnhundert, und TMDB begrenzt, wie oft man fragen darf. Der eigene Abruf der IMDb-Kennung ist dabei entfallen — sie hängt jetzt mit dran. Freigaben sind national: die FSK gilt in Deutschland, die MPAA in den USA, und TMDB führt beide nebeneinander. Genommen wird die des eingestellten Landes. Beim Schriftzug entscheidet zuerst die Sprache und erst danach die Bildbewertung — ein deutscher Titel in deutscher Schrift schlägt den besser bewerteten englischen. Vom Stab nur Regie und Drehbuch. TMDB führt dort auch Beleuchter und Caterer; eine Detailseite, die alle nennt, nennt nichts. **Genres und Personen als eigene Tabellen**, nicht als Text im Film. Sonst: filtern über `LIKE`, Tippfehler als eigenes Genre, und bei einer Umbenennung vierhundert Zeilen anfassen. Personen über die Kennung der Quelle geführt, nicht über den Namen — es gibt zwei Schauspieler namens „Chris Evans". Darauf sitzen zwei neue Endpunkte: ein Filter nach Genre (nur die, die in der Mediathek auch vorkommen — ein Knopf zu einer leeren Liste ist schlimmer als kein Knopf) und **Vorschläge** über die Genres des zuletzt Gesehenen. Keine Empfehlungsmaschine: ein Haushaltsserver hat keine Millionen Sehverläufe, aus denen sich etwas lernen liesse. Eine schlichte Regel ist nachvollziehbar, was eine undurchschaubare Empfehlung nie ist. --- **Zwei Fehler, beide fand erst der echte Lauf.** `UNIQUE constraint failed: Genres.Name`, und damit scheiterte der ganze Scan — in der Mediathek erschien kein einziger Titel. Vier Bibliotheken scannen gleichzeitig, jede mit eigenem Datenbankkontext; zwei stossen auf „Drama", keine sieht die ungespeicherte Zeile der anderen. Statt die Läufe zu serialisieren — das kostete bei zehn Bibliotheken das Zehnfache an Zeit — wird der Fehlschlag hingenommen und die Zeile des anderen benutzt. Sie steht ja genau dafür da. Und: **eine geänderte Sprache erreichte vorhandene Titel nie.** Ein zweiter Scan fragt nichts erneut, das ist Absicht. Wer aber von Englisch auf Deutsch stellt, bekam deutsche Titel erst beim nächsten neuen Film. Dafür gibt es jetzt „Metadaten auffrischen" je Bibliothek — geholt über die bekannte Kennung, nicht über eine neue Suche, damit eine Zuordnung von Hand stehenbleibt. 368 Tests grün, zehn davon neu. Am Container nachgewiesen: „Der Untergang", 156 Minuten, FSK 12, Bewertung 7,9, Genres Drama/Historie/Kriegsfilm, 22 Mitwirkende mit Rollennamen, Schriftzug und Hintergrund da — und nach dem Umstellen auf de-DE heisst er nicht mehr „Downfall". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
439 lines
22 KiB
C#
439 lines
22 KiB
C#
using System.Security.Claims;
|
|
using Flaro.Core.Playback;
|
|
using Flaro.Data;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace Flaro.Api.Endpoints;
|
|
|
|
public static class LibraryContentEndpoints
|
|
{
|
|
/// <summary>Mehr als das liefert keine Anfrage — auch wenn sie danach fragt.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// Ohne Obergrenze holt ein <c>?take=100000</c> 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.
|
|
/// </remarks>
|
|
private const int HöchstensJeSeite = 200;
|
|
|
|
/// <summary>Wonach eine Liste geordnet wird.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <b>Als Wort, nicht als Spaltenname.</b> „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.
|
|
/// </remarks>
|
|
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);
|
|
}
|
|
|
|
/// <summary>Holt die Spuren aller genannten Dateien in einer Abfrage.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// Je Datei einzeln zu fragen hiesse: fünfzig Titel, fünfzig Abfragen —
|
|
/// und SQLite führt die nacheinander aus.
|
|
/// </remarks>
|
|
private static async Task<ILookup<Guid, MediaStreamResponse>> SpurenZu(FlaroContext db, List<Guid> 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<Guid, MediaStreamResponse> spuren) =>
|
|
new(id, System.IO.Path.GetFileName(pfad), bytes, schachtel, dauer?.TotalSeconds, spuren[id].ToList());
|
|
|
|
/// <summary>Die Genres eines Titels, in der Reihenfolge der Quelle.</summary>
|
|
private static async Task<IReadOnlyList<string>> 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();
|
|
|
|
/// <summary>Besetzung und Stab — Darsteller zuerst, in ihrer Reihenfolge.</summary>
|
|
private static async Task<IReadOnlyList<CreditResponse>> 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();
|
|
|
|
/// <summary>Die Stände dieses Benutzers zu den genannten Titeln.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// 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 <c>null</c>.
|
|
/// </remarks>
|
|
private static async Task<Dictionary<Guid, ItemStateResponse>> ZuständeZu(
|
|
FlaroContext db, ClaimsPrincipal wer, List<Guid>? filme = null, List<Guid>? 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);
|
|
}
|
|
|
|
/// <summary>Welche der genannten Folgen dieser Benutzer gesehen hat.</summary>
|
|
private static async Task<HashSet<Guid>> GeseheneFolgen(
|
|
FlaroContext db, ClaimsPrincipal wer, List<Guid> 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<MovieResponse>(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<SeriesResponse>(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<EpisodeResponse>(items, items.Count));
|
|
}).RequireAuthorization();
|
|
}
|
|
}
|