diff --git a/server/src/Flaro.Api/Endpoints/Contracts.cs b/server/src/Flaro.Api/Endpoints/Contracts.cs index 3827d37..4e7d481 100644 --- a/server/src/Flaro.Api/Endpoints/Contracts.cs +++ b/server/src/Flaro.Api/Endpoints/Contracts.cs @@ -86,7 +86,25 @@ public sealed record MovieResponse( Guid Id, string Title, int? Year, string? Overview, string? PosterImage, string? BackdropImage, IReadOnlyList Files, - ItemStateResponse? State); + ItemStateResponse? State, + /// Der Schriftzug des Titels — fehlt oft, und das ist in Ordnung. + string? LogoImage = null, + int? RuntimeMinutes = null, + double? Rating = null, + string? Tagline = null, + string? ContentRating = null, + /// Leer in Listen, gefüllt beim Einzelabruf. + /// + /// + /// Eine Seite mit fünfzig Titeln braucht keine Besetzungslisten. + /// Sie mitzuschicken hiesse, tausend Namen über die Leitung zu schicken, + /// von denen keiner angezeigt wird. + /// + IReadOnlyList? Genres = null, + IReadOnlyList? Credits = null); + +public sealed record CreditResponse( + Guid Id, string Name, string Role, string? Character, string? Image); public sealed record SeriesResponse( Guid Id, string Title, int? Year, string? Overview, @@ -99,7 +117,14 @@ public sealed record SeriesResponse( /// ihre Folgen werden es. Was man an der Kachel sehen will, ist die Zahl /// — bei Plex steht sie als Plakette in der Ecke. /// - int UnwatchedCount); + int UnwatchedCount, + string? LogoImage = null, + int? RuntimeMinutes = null, + double? Rating = null, + string? Tagline = null, + string? ContentRating = null, + IReadOnlyList? Genres = null, + IReadOnlyList? Credits = null); public sealed record EpisodeResponse( Guid Id, int Season, int Episode, string? Title, string? Overview, string? StillImage, diff --git a/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs b/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs index d42df36..e4ddefc 100644 --- a/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs @@ -65,6 +65,24 @@ public static class LibraryContentEndpoints 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. /// /// @@ -121,6 +139,26 @@ public static class LibraryContentEndpoints 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); @@ -145,12 +183,18 @@ public static class LibraryContentEndpoints { app.MapGet("/api/v1/movies", async ( ClaimsPrincipal wer, FlaroContext db, - Guid? library, string? search, string? sort, int skip = 0, int take = 50) => + 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 @@ -165,7 +209,11 @@ public static class LibraryContentEndpoints 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 }) + .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. @@ -183,19 +231,26 @@ public static class LibraryContentEndpoints 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))).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, int skip = 0, int take = 50) => + 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 { @@ -207,7 +262,11 @@ public static class LibraryContentEndpoints 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 }) + .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(); @@ -227,12 +286,38 @@ public static class LibraryContentEndpoints 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))); + 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. @@ -241,7 +326,6 @@ public static class LibraryContentEndpoints { var film = await db.Movies.AsNoTracking() .Where(m => m.Id == id) - .Select(m => new { m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage }) .FirstOrDefaultAsync(); if (film is null) return Results.NotFound(); @@ -255,7 +339,10 @@ public static class LibraryContentEndpoints 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))); + 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 ( @@ -263,7 +350,6 @@ public static class LibraryContentEndpoints { var serie = await db.Series.AsNoTracking() .Where(x => x.Id == id) - .Select(x => new { x.Id, x.Title, x.Year, x.Overview, x.PosterImage, x.BackdropImage }) .FirstOrDefaultAsync(); if (serie is null) return Results.NotFound(); @@ -276,7 +362,10 @@ public static class LibraryContentEndpoints 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)))); + 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 diff --git a/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs b/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs index 1051d2f..09b46b7 100644 --- a/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs @@ -246,6 +246,71 @@ public static class PlaybackEndpoints return Results.Ok(new Page(liste, liste.Count)); }); + + // **Ähnliches — „weil du X gesehen hast".** + // + // Über die Genres der zuletzt gesehenen Titel, nicht über eine + // Empfehlungsmaschine. Ein Haushaltsserver hat keine Millionen + // Sehverläufe, aus denen sich etwas lernen liesse; was er hat, sind + // die Genres dessen, was dieser eine Mensch angefangen hat. Das ist + // eine schlichte Regel — und sie ist nachvollziehbar, was eine + // undurchschaubare Empfehlung nie ist. + start.MapGet("/suggestions", async (ClaimsPrincipal wer, FlaroContext db, int take = 20) => + { + if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized(); + + // Woran dieser Mensch zuletzt dran war — Filme wie Folgen. + var zuletzt = await db.UserItemStates.AsNoTracking() + .Where(z => z.UserId == benutzer && (z.Watched || z.Position > TimeSpan.Zero)) + .OrderByDescending(z => z.UpdatedAt) + .Take(30) + .ToListAsync(); + + if (zuletzt.Count == 0) return Results.Ok(new Page([], 0)); + + var filmIds = zuletzt.Where(z => z.MovieId != null).Select(z => z.MovieId!.Value).ToList(); + var folgenIds = zuletzt.Where(z => z.EpisodeId != null).Select(z => z.EpisodeId!.Value).ToList(); + var serienIds = await db.Episodes.AsNoTracking() + .Where(f => folgenIds.Contains(f.Id)).Select(f => f.SeriesId).Distinct().ToListAsync(); + + // Die Genres daraus, nach Häufigkeit: wer dreimal Science Fiction + // angefangen hat, meint Science Fiction. + var genres = await db.GenreLinks.AsNoTracking() + .Where(l => (l.MovieId != null && filmIds.Contains(l.MovieId!.Value)) + || (l.SeriesId != null && serienIds.Contains(l.SeriesId!.Value))) + .GroupBy(l => l.GenreId) + .OrderByDescending(g => g.Count()) + .Select(g => g.Key) + .Take(5) + .ToListAsync(); + + if (genres.Count == 0) return Results.Ok(new Page([], 0)); + + // **Nichts vorschlagen, was schon angefasst wurde.** Ein + // Vorschlag, den man gestern gesehen hat, ist kein Vorschlag. + var beruehrt = filmIds.ToHashSet(); + + var treffer = await db.Movies.AsNoTracking() + .Where(m => !beruehrt.Contains(m.Id)) + .Where(m => db.GenreLinks.Any(l => l.MovieId == m.Id && genres.Contains(l.GenreId))) + // Je mehr gemeinsame Genres, desto besser der Vorschlag. + .Select(m => new + { + Film = m, + Treffer = db.GenreLinks.Count(l => l.MovieId == m.Id && genres.Contains(l.GenreId)), + }) + .OrderByDescending(x => x.Treffer) + .ThenByDescending(x => x.Film.Rating ?? 0) + .Take(Math.Clamp(take, 1, HöchstensJeSeite)) + .Select(x => x.Film) + .ToListAsync(); + + var items = treffer.Select(m => new MovieResponse( + m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage, + [], null, m.LogoImage, m.RuntimeMinutes, m.Rating, null, m.ContentRating)).ToList(); + + return Results.Ok(new Page(items, items.Count)); + }); } /// Der Zustand eines Titels, den noch niemand angefasst hat. diff --git a/server/src/Flaro.Api/ScanCoordinator.cs b/server/src/Flaro.Api/ScanCoordinator.cs index 84158e5..f46776a 100644 --- a/server/src/Flaro.Api/ScanCoordinator.cs +++ b/server/src/Flaro.Api/ScanCoordinator.cs @@ -43,7 +43,7 @@ public sealed class ScanCoordinator( public bool IsRunning(Guid libraryId) => State(libraryId)?.Status == "running"; /// Startet einen Lauf — oder sagt, dass schon einer läuft. - public bool TryStart(Guid libraryId) + public bool TryStart(Guid libraryId, bool erneut = false) { var start = new ScanState("running", clock.GetUtcNow(), null, null, null, null); @@ -55,11 +55,11 @@ public sealed class ScanCoordinator( // Lief schon einer, steht hier dessen Zustand und nicht unserer. if (!ReferenceEquals(vorher, start)) return false; - _ = Task.Run(() => Laufen(libraryId)); + _ = Task.Run(() => Laufen(libraryId, erneut)); return true; } - private async Task Laufen(Guid libraryId) + private async Task Laufen(Guid libraryId, bool erneut) { try { @@ -73,7 +73,7 @@ public sealed class ScanCoordinator( _zustand[libraryId] = _zustand[libraryId] with { Status = "enriching", Report = bericht }; var metadaten = await bereich.ServiceProvider - .GetRequiredService().EnrichAsync(libraryId); + .GetRequiredService().EnrichAsync(libraryId, erneut); _zustand[libraryId] = _zustand[libraryId] with { diff --git a/server/src/Flaro.Core/Library/CatalogEntities.cs b/server/src/Flaro.Core/Library/CatalogEntities.cs new file mode 100644 index 0000000..ff1da85 --- /dev/null +++ b/server/src/Flaro.Core/Library/CatalogEntities.cs @@ -0,0 +1,66 @@ +namespace Flaro.Core.Library; + +/// Ein Genre, einmal je Name. +/// +/// +/// Eine eigene Tabelle und keine Liste im Film. „Science Fiction" als +/// Text in jedem Film stehen zu haben hiesse: filtern über LIKE, +/// Tippfehler als eigenes Genre, und bei einer Umbenennung vierhundert +/// Zeilen anfassen. So ist es eine Zeile und ein Verweis. +/// +public sealed class Genre +{ + public Guid Id { get; init; } = Guid.CreateVersion7(); + public required string Name { get; set; } +} + +/// Verbindet ein Genre mit einem Film oder einer Serie. +public sealed class GenreLink +{ + public Guid Id { get; init; } = Guid.CreateVersion7(); + public required Guid GenreId { get; init; } + public Guid? MovieId { get; init; } + public Guid? SeriesId { get; init; } +} + +/// Jemand, der mitgewirkt hat. +/// +/// +/// Über die Kennung der Quelle geführt, nicht über den Namen: es gibt zwei +/// Schauspieler namens „Chris Evans", und sie über einen Kamm zu scheren +/// hiesse, dem einen die Filmografie des anderen anzuhängen. +/// +public sealed class Person +{ + public Guid Id { get; init; } = Guid.CreateVersion7(); + public required string Name { get; set; } + + /// Die Kennung bei TMDB — der eindeutige Schlüssel. + public required int TmdbId { get; init; } + + /// Das Porträt in der Bildablage. + public string? Image { get; set; } +} + +public enum CreditRole { Actor, Director, Writer } + +/// Wer in welcher Rolle woran beteiligt war. +public sealed class Credit +{ + public Guid Id { get; init; } = Guid.CreateVersion7(); + public required Guid PersonId { get; init; } + public Guid? MovieId { get; init; } + public Guid? SeriesId { get; init; } + public required CreditRole Role { get; init; } + + /// Die gespielte Rolle — nur bei Darstellern. + public string? Character { get; set; } + + /// Die Reihenfolge, wie die Quelle sie nennt. + /// + /// + /// Die Besetzungsliste ist nicht alphabetisch: oben stehen die + /// Hauptrollen. Nach Namen zu sortieren hiesse, diese Aussage wegzuwerfen. + /// + public int Order { get; set; } +} diff --git a/server/src/Flaro.Core/Library/MediaEntities.cs b/server/src/Flaro.Core/Library/MediaEntities.cs index 8fc59e3..6da5dc6 100644 --- a/server/src/Flaro.Core/Library/MediaEntities.cs +++ b/server/src/Flaro.Core/Library/MediaEntities.cs @@ -121,6 +121,33 @@ public sealed class Movie public string? BackdropImage { get; set; } + /// Die Laufzeit laut Quelle, in Minuten. + /// + /// + /// Nicht die der Datei — die steht in + /// und misst, was wirklich vorliegt. Beide sind nützlich und beide + /// verschieden: eine Fassung mit Werbepausen ist länger als der Film. + /// + public int? RuntimeMinutes { get; set; } + + /// Die Bewertung der Quelle, 0–10. + public double? Rating { get; set; } + + /// Die Werbezeile, etwa „Die Wirklichkeit ist eine Lüge". + public string? Tagline { get; set; } + + /// Die Altersfreigabe des eingestellten Landes, etwa „16". + public string? ContentRating { get; set; } + + /// Der Schriftzug des Titels in der Bildablage. + /// + /// + /// Freigestellt auf durchsichtigem Grund. Über dem Hintergrundbild sagt + /// er mehr als derselbe Titel in der Systemschrift — und nicht jeder + /// Titel hat einen. + /// + public string? LogoImage { get; set; } + public DateTimeOffset AddedAt { get; init; } = DateTimeOffset.UtcNow; } @@ -184,6 +211,33 @@ public sealed class Series public string? BackdropImage { get; set; } + /// Die Laufzeit laut Quelle, in Minuten. + /// + /// + /// Nicht die der Datei — die steht in + /// und misst, was wirklich vorliegt. Beide sind nützlich und beide + /// verschieden: eine Fassung mit Werbepausen ist länger als der Film. + /// + public int? RuntimeMinutes { get; set; } + + /// Die Bewertung der Quelle, 0–10. + public double? Rating { get; set; } + + /// Die Werbezeile, etwa „Die Wirklichkeit ist eine Lüge". + public string? Tagline { get; set; } + + /// Die Altersfreigabe des eingestellten Landes, etwa „16". + public string? ContentRating { get; set; } + + /// Der Schriftzug des Titels in der Bildablage. + /// + /// + /// Freigestellt auf durchsichtigem Grund. Über dem Hintergrundbild sagt + /// er mehr als derselbe Titel in der Systemschrift — und nicht jeder + /// Titel hat einen. + /// + public string? LogoImage { get; set; } + public DateTimeOffset AddedAt { get; init; } = DateTimeOffset.UtcNow; } diff --git a/server/src/Flaro.Core/Metadata/IMetadataProvider.cs b/server/src/Flaro.Core/Metadata/IMetadataProvider.cs index 4610d26..6f79600 100644 --- a/server/src/Flaro.Core/Metadata/IMetadataProvider.cs +++ b/server/src/Flaro.Core/Metadata/IMetadataProvider.cs @@ -3,13 +3,78 @@ namespace Flaro.Core.Metadata; /// Ein Bild bei der Quelle — noch nicht heruntergeladen. public sealed record RemoteImage(string Url); +/// Jemand, der mitgewirkt hat. +/// +/// +/// Character ist nur bei Darstellern gefüllt. Regie und Drehbuch +/// haben keine Rolle, und ein leeres Feld dafür wäre ehrlicher als „—". +/// +public sealed record CreditMetadata( + int ProviderId, string Name, CreditKind Kind, string? Character, RemoteImage? Portrait, int Order); + +public enum CreditKind { Actor, Director, Writer } + +/// Was eine Quelle über einen Film weiss. +/// +/// +/// Gewachsen, und das hat einen Grund. Mit Titel, Jahr, Beschreibung +/// und zwei Bildern 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 hängt daran, dass hier mehr ankommt. +/// +/// Jedes Feld darf fehlen. Ein Mitschnitt, den keine Quelle kennt, soll +/// trotzdem in der Mediathek stehen. +/// public sealed record MovieMetadata( int ProviderId, string Title, int? Year, string? Overview, - RemoteImage? Poster, RemoteImage? Backdrop, string? ImdbId = null); + RemoteImage? Poster, RemoteImage? Backdrop, string? ImdbId = null, + /// Laufzeit in Minuten, wie die Quelle sie nennt. + /// + /// + /// Nicht die der Datei: die steht in MediaFile.Duration und misst, + /// was wirklich vorliegt. Beide sind nützlich und beide verschieden — + /// eine Fassung mit Werbepausen ist länger als der Film. + /// + int? RuntimeMinutes = null, + /// Die Bewertung der Quelle, 0–10. + double? Rating = null, + string? Tagline = null, + /// Die Altersfreigabe des eingestellten Landes, etwa „16". + string? ContentRating = null, + IReadOnlyList? Genres = null, + IReadOnlyList? Credits = null, + /// Der Schriftzug des Titels, freigestellt auf durchsichtigem Grund. + /// + /// + /// Das, was eine Startseite teuer aussehen lässt. „Blade Runner + /// 2049" in seiner eigenen Schrift über dem Hintergrundbild sagt mehr als + /// derselbe Titel in der Systemschrift — und es ist der Unterschied + /// zwischen einer Kachelwand und etwas, das wie ein Filmdienst aussieht. + /// + /// Nicht jeder Titel hat eines. Fehlt es, steht der Titel als Text da. + /// + RemoteImage? Logo = null); public sealed record SeriesMetadata( int ProviderId, string Title, int? Year, string? Overview, - RemoteImage? Poster, RemoteImage? Backdrop, string? ImdbId = null); + RemoteImage? Poster, RemoteImage? Backdrop, string? ImdbId = null, + int? RuntimeMinutes = null, + double? Rating = null, + string? Tagline = null, + string? ContentRating = null, + IReadOnlyList? Genres = null, + IReadOnlyList? Credits = null, + /// Der Schriftzug des Titels, freigestellt auf durchsichtigem Grund. + /// + /// + /// Das, was eine Startseite teuer aussehen lässt. „Blade Runner + /// 2049" in seiner eigenen Schrift über dem Hintergrundbild sagt mehr als + /// derselbe Titel in der Systemschrift — und es ist der Unterschied + /// zwischen einer Kachelwand und etwas, das wie ein Filmdienst aussieht. + /// + /// Nicht jeder Titel hat eines. Fehlt es, steht der Titel als Text da. + /// + RemoteImage? Logo = null); public sealed record EpisodeMetadata(string? Title, string? Overview, RemoteImage? Still); diff --git a/server/src/Flaro.Data/FlaroContext.cs b/server/src/Flaro.Data/FlaroContext.cs index b3260a7..c24b675 100644 --- a/server/src/Flaro.Data/FlaroContext.cs +++ b/server/src/Flaro.Data/FlaroContext.cs @@ -34,6 +34,10 @@ public sealed class FlaroContext(DbContextOptions options) : DbCon public DbSet Episodes => Set(); public DbSet MediaStreams => Set(); public DbSet UserItemStates => Set(); + public DbSet Genres => Set(); + public DbSet GenreLinks => Set(); + public DbSet People => Set(); + public DbSet Credits => Set(); /// Gilt für jede Zeitangabe im Modell, ohne sie einzeln zu nennen. /// @@ -124,6 +128,8 @@ public sealed class FlaroContext(DbContextOptions options) : DbCon e.Property(x => x.Title).HasMaxLength(512); e.Property(x => x.SortTitleValue).HasMaxLength(512); e.Property(x => x.ImdbId).HasMaxLength(32); + e.Property(x => x.Tagline).HasMaxLength(512); + e.Property(x => x.ContentRating).HasMaxLength(16); e.HasOne().WithMany().HasForeignKey(x => x.LibraryId) .OnDelete(DeleteBehavior.Cascade); }); @@ -135,6 +141,8 @@ public sealed class FlaroContext(DbContextOptions options) : DbCon e.Property(x => x.Title).HasMaxLength(512); e.Property(x => x.SortTitleValue).HasMaxLength(512); e.Property(x => x.ImdbId).HasMaxLength(32); + e.Property(x => x.Tagline).HasMaxLength(512); + e.Property(x => x.ContentRating).HasMaxLength(16); e.HasOne().WithMany().HasForeignKey(x => x.LibraryId) .OnDelete(DeleteBehavior.Cascade); }); @@ -152,6 +160,49 @@ public sealed class FlaroContext(DbContextOptions options) : DbCon e.HasOne().WithMany().HasForeignKey(x => x.MediaFileId).OnDelete(DeleteBehavior.Cascade); }); + b.Entity(e => + { + e.HasKey(x => x.Id); + // Ein Name genau einmal. „Science Fiction" zweimal in der Tabelle + // hiesse: ein Filter, der die Hälfte übersieht. + e.HasIndex(x => x.Name).IsUnique(); + e.Property(x => x.Name).HasMaxLength(64).UseCollation("NOCASE"); + }); + + b.Entity(e => + { + e.HasKey(x => x.Id); + e.HasIndex(x => new { x.GenreId, x.MovieId }).IsUnique().HasFilter("\"MovieId\" IS NOT NULL"); + e.HasIndex(x => new { x.GenreId, x.SeriesId }).IsUnique().HasFilter("\"SeriesId\" IS NOT NULL"); + e.HasIndex(x => x.MovieId); + e.HasIndex(x => x.SeriesId); + e.HasOne().WithMany().HasForeignKey(x => x.GenreId).OnDelete(DeleteBehavior.Cascade); + e.HasOne().WithMany().HasForeignKey(x => x.MovieId).OnDelete(DeleteBehavior.Cascade); + e.HasOne().WithMany().HasForeignKey(x => x.SeriesId).OnDelete(DeleteBehavior.Cascade); + }); + + b.Entity(e => + { + e.HasKey(x => x.Id); + // Über die Kennung der Quelle, nicht über den Namen: es gibt zwei + // Schauspieler namens „Chris Evans". + e.HasIndex(x => x.TmdbId).IsUnique(); + e.Property(x => x.Name).HasMaxLength(256); + }); + + b.Entity(e => + { + e.HasKey(x => x.Id); + e.HasIndex(x => x.MovieId); + e.HasIndex(x => x.SeriesId); + e.HasIndex(x => x.PersonId); + e.Property(x => x.Character).HasMaxLength(256); + e.Property(x => x.Role).HasConversion().HasMaxLength(16); + e.HasOne().WithMany().HasForeignKey(x => x.PersonId).OnDelete(DeleteBehavior.Cascade); + e.HasOne().WithMany().HasForeignKey(x => x.MovieId).OnDelete(DeleteBehavior.Cascade); + e.HasOne().WithMany().HasForeignKey(x => x.SeriesId).OnDelete(DeleteBehavior.Cascade); + }); + b.Entity(e => { e.HasKey(x => x.Id); diff --git a/server/src/Flaro.Data/MetadataEnricher.cs b/server/src/Flaro.Data/MetadataEnricher.cs index 9907549..8eb67f9 100644 --- a/server/src/Flaro.Data/MetadataEnricher.cs +++ b/server/src/Flaro.Data/MetadataEnricher.cs @@ -32,7 +32,18 @@ public sealed class MetadataEnricher( ISidecarStore beiwerk, ILogger log) { - public async Task EnrichAsync(Guid libraryId, CancellationToken ct = default) + /// + /// Auch holen, was schon zugeordnet ist. Im Normalfall fragt ein + /// zweiter Lauf nichts erneut — das ist schneller und hält Handarbeit. + /// Aber wer die Sprache von Englisch auf Deutsch stellt, bekommt sonst + /// nie deutsche Titel: die Einstellung griffe erst beim nächsten neuen + /// Film. Dasselbe gilt, wenn eine Quelle Beschreibungen nachträgt. + /// + /// Nachgeholt wird über die bekannte Kennung, nicht über eine neue + /// Suche. Eine Zuordnung von Hand bleibt damit stehen. + /// + public async Task EnrichAsync( + Guid libraryId, bool erneut = false, CancellationToken ct = default) { if (!await anbieter.IsConfiguredAsync(ct)) { @@ -43,9 +54,9 @@ public sealed class MetadataEnricher( int filme = 0, serien = 0, folgen = 0; try { - filme = await FilmeZuordnen(libraryId, ct); - serien = await SerienZuordnen(libraryId, ct); - folgen = await FolgenZuordnen(libraryId, ct); + filme = await FilmeZuordnen(libraryId, erneut, ct); + serien = await SerienZuordnen(libraryId, erneut, ct); + folgen = await FolgenZuordnen(libraryId, erneut, ct); } catch (MetadataCredentialsException fehler) { @@ -60,9 +71,11 @@ public sealed class MetadataEnricher( return new EnrichReport(filme, serien, folgen, Skipped: false, null); } - private async Task FilmeZuordnen(Guid libraryId, CancellationToken ct) + private async Task FilmeZuordnen(Guid libraryId, bool erneut, CancellationToken ct) { - var offen = await db.Movies.Where(m => m.LibraryId == libraryId && m.TmdbId == null).ToListAsync(ct); + var offen = await db.Movies + .Where(m => m.LibraryId == libraryId && (erneut || m.TmdbId == null)) + .ToListAsync(ct); var zugeordnet = 0; foreach (var film in offen) @@ -91,7 +104,15 @@ public sealed class MetadataEnricher( film.Overview = gefunden.Overview; film.PosterImage = await BildAblegen(gefunden.Poster, ct); film.BackdropImage = await BildAblegen(gefunden.Backdrop, ct); + film.LogoImage = await BildAblegen(gefunden.Logo, ct); + film.RuntimeMinutes = gefunden.RuntimeMinutes; + film.Rating = gefunden.Rating; + film.Tagline = gefunden.Tagline; + film.ContentRating = gefunden.ContentRating; film.MatchLocked = festgelegt; + + await GenresSetzen(gefunden.Genres, film: film.Id, serie: null, ct); + await MitwirkendeSetzen(gefunden.Credits, film: film.Id, serie: null, ct); zugeordnet++; if (datei is not null) await FilmBeiwerk(film, datei, ct); @@ -121,9 +142,11 @@ public sealed class MetadataEnricher( return (new ProviderIds(ausNfo.Tmdb ?? ausPfad.Tmdb, ausNfo.Imdb ?? ausPfad.Imdb), Nfo.ReadLocked(nfo)); } - private async Task SerienZuordnen(Guid libraryId, CancellationToken ct) + private async Task SerienZuordnen(Guid libraryId, bool erneut, CancellationToken ct) { - var offen = await db.Series.Where(s => s.LibraryId == libraryId && s.TmdbId == null).ToListAsync(ct); + var offen = await db.Series + .Where(s => s.LibraryId == libraryId && (erneut || s.TmdbId == null)) + .ToListAsync(ct); var zugeordnet = 0; foreach (var serie in offen) @@ -153,6 +176,14 @@ public sealed class MetadataEnricher( serie.Overview = gefunden.Overview; serie.PosterImage = await BildAblegen(gefunden.Poster, ct); serie.BackdropImage = await BildAblegen(gefunden.Backdrop, ct); + serie.LogoImage = await BildAblegen(gefunden.Logo, ct); + serie.RuntimeMinutes = gefunden.RuntimeMinutes; + serie.Rating = gefunden.Rating; + serie.Tagline = gefunden.Tagline; + serie.ContentRating = gefunden.ContentRating; + + await GenresSetzen(gefunden.Genres, film: null, serie: serie.Id, ct); + await MitwirkendeSetzen(gefunden.Credits, film: null, serie: serie.Id, ct); serie.MatchLocked = festgelegt; zugeordnet++; @@ -199,7 +230,7 @@ public sealed class MetadataEnricher( } /// Folgen nachschlagen — aber nur bei Serien, die zugeordnet sind. - private async Task FolgenZuordnen(Guid libraryId, CancellationToken ct) + private async Task FolgenZuordnen(Guid libraryId, bool erneut, CancellationToken ct) { var serien = await db.Series .Where(s => s.LibraryId == libraryId && s.TmdbId != null) @@ -209,7 +240,7 @@ public sealed class MetadataEnricher( var kennungen = serien.Select(s => s.Id).ToList(); var offen = await db.Episodes - .Where(f => kennungen.Contains(f.SeriesId) && f.Overview == null) + .Where(f => kennungen.Contains(f.SeriesId) && (erneut || f.Overview == null)) .ToListAsync(ct); var zugeordnet = 0; @@ -236,6 +267,143 @@ public sealed class MetadataEnricher( return zugeordnet; } + /// Schreibt die Genres eines Titels — alte weg, neue hin. + /// + /// + /// Ersetzen statt ergänzen. Nach einer Korrektur von Hand ist es + /// ein anderer Film; die Genres des vorigen stehen zu lassen hiesse, aus + /// zwei Zuordnungen eine falsche dritte zu machen. + /// + private async Task GenresSetzen( + IReadOnlyList? namen, Guid? film, Guid? serie, CancellationToken ct) + { + await db.GenreLinks + .Where(l => film != null ? l.MovieId == film : l.SeriesId == serie) + .ExecuteDeleteAsync(ct); + + if (namen is not { Count: > 0 }) return; + + foreach (var name in namen) + { + db.GenreLinks.Add(new GenreLink + { + GenreId = await GenreKennung(name, ct), MovieId = film, SeriesId = serie, + }); + } + } + + /// Die Kennung eines Genres — angelegt, falls es sie nicht gibt. + /// + /// + /// Sofort gespeichert, und mit einem zweiten Blick nach dem Fehlschlag. + /// + /// Der Scan läuft je Bibliothek, und vier Bibliotheken scannen gleichzeitig + /// — jede mit eigenem Datenbankkontext. Zwei davon stossen auf „Drama", + /// keine sieht die ungespeicherte Zeile der anderen, und beide legen sie + /// an. Am Ende scheitert der ganze Lauf an + /// UNIQUE constraint failed: Genres.Name, und zwar mitsamt allem, + /// was sonst noch im Stapel stand: in der Mediathek erschien kein + /// einziger Titel. + /// + /// 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. + /// + private async Task GenreKennung(string name, CancellationToken ct) + { + var vorhanden = db.Genres.Local + .FirstOrDefault(g => string.Equals(g.Name, name, StringComparison.OrdinalIgnoreCase)) + ?? await db.Genres.FirstOrDefaultAsync(g => g.Name == name, ct); + if (vorhanden is not null) return vorhanden.Id; + + var neu = new Genre { Name = name }; + db.Genres.Add(neu); + try + { + await db.SaveChangesAsync(ct); + return neu.Id; + } + catch (DbUpdateException) + { + // Ein anderer Lauf war schneller. Die eigene Zeile wieder + // vergessen, sonst versucht der nächste Speichervorgang sie + // erneut — und scheitert wieder. + db.Entry(neu).State = EntityState.Detached; + var fremd = await db.Genres.FirstOrDefaultAsync(g => g.Name == name, ct); + if (fremd is not null) return fremd.Id; + throw; + } + } + + /// Schreibt Besetzung und Stab — ebenfalls ersetzend. + private async Task MitwirkendeSetzen( + IReadOnlyList? mitwirkende, Guid? film, Guid? serie, CancellationToken ct) + { + await db.Credits + .Where(c => film != null ? c.MovieId == film : c.SeriesId == serie) + .ExecuteDeleteAsync(ct); + + if (mitwirkende is not { Count: > 0 }) return; + + foreach (var eintrag in mitwirkende) + { + db.Credits.Add(new Credit + { + PersonId = await PersonKennung(eintrag, ct), + MovieId = film, + SeriesId = serie, + Role = eintrag.Kind switch + { + CreditKind.Director => CreditRole.Director, + CreditKind.Writer => CreditRole.Writer, + _ => CreditRole.Actor, + }, + Character = eintrag.Character, + Order = eintrag.Order, + }); + } + } + + /// Die Kennung einer Person — dieselbe Vorsicht wie beim Genre. + /// + /// + /// Dieselbe Lage: zwei Bibliotheken, zwei Kontexte, derselbe Schauspieler. + /// + private async Task PersonKennung(CreditMetadata eintrag, CancellationToken ct) + { + var vorhanden = db.People.Local.FirstOrDefault(p => p.TmdbId == eintrag.ProviderId) + ?? await db.People.FirstOrDefaultAsync(p => p.TmdbId == eintrag.ProviderId, ct); + + if (vorhanden is not null) + { + // Der Name kann sich ändern, und ein Porträt kann nachkommen. Ein + // vorhandenes gegen nichts zu tauschen wäre dagegen ein Rückschritt. + vorhanden.Name = eintrag.Name; + vorhanden.Image ??= await BildAblegen(eintrag.Portrait, ct); + return vorhanden.Id; + } + + var neu = new Person + { + Name = eintrag.Name, + TmdbId = eintrag.ProviderId, + Image = await BildAblegen(eintrag.Portrait, ct), + }; + db.People.Add(neu); + try + { + await db.SaveChangesAsync(ct); + return neu.Id; + } + catch (DbUpdateException) + { + db.Entry(neu).State = EntityState.Detached; + var fremd = await db.People.FirstOrDefaultAsync(p => p.TmdbId == eintrag.ProviderId, ct); + if (fremd is not null) return fremd.Id; + throw; + } + } + private async Task BildAblegen(RemoteImage? bild, CancellationToken ct) => bild is null ? null : await bilder.StoreAsync(bild, ct); diff --git a/server/src/Flaro.Data/Migrations/20261003210324_Katalog.Designer.cs b/server/src/Flaro.Data/Migrations/20261003210324_Katalog.Designer.cs new file mode 100644 index 0000000..3a5f8bd --- /dev/null +++ b/server/src/Flaro.Data/Migrations/20261003210324_Katalog.Designer.cs @@ -0,0 +1,774 @@ +// +using System; +using Flaro.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Flaro.Data.Migrations +{ + [DbContext(typeof(FlaroContext))] + [Migration("20261003210324_Katalog")] + partial class Katalog + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.12"); + + modelBuilder.Entity("Flaro.Core.Auth.AuthCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ApprovedByUserId") + .HasColumnType("TEXT"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("ConsumedAt") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("INTEGER"); + + b.Property("HandleFingerprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("HandleFingerprint") + .IsUnique(); + + b.ToTable("AuthCodes"); + }); + + modelBuilder.Entity("Flaro.Core.Auth.DeviceToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("INTEGER"); + + b.Property("RevokedAt") + .HasColumnType("INTEGER"); + + b.Property("TokenFingerprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TokenFingerprint") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("DeviceTokens"); + }); + + modelBuilder.Entity("Flaro.Core.Libraries.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Libraries"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Credit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Character") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MovieId"); + + b.HasIndex("PersonId"); + + b.HasIndex("SeriesId"); + + b.ToTable("Credits"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Episode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedAt") + .HasColumnType("INTEGER"); + + b.Property("EpisodeNumber") + .HasColumnType("INTEGER"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("SeasonNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("StillImage") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId", "SeasonNumber", "EpisodeNumber") + .IsUnique(); + + b.ToTable("Episodes"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Genres"); + }); + + modelBuilder.Entity("Flaro.Core.Library.GenreLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("GenreId") + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MovieId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("GenreId", "MovieId") + .IsUnique() + .HasFilter("\"MovieId\" IS NOT NULL"); + + b.HasIndex("GenreId", "SeriesId") + .IsUnique() + .HasFilter("\"SeriesId\" IS NOT NULL"); + + b.ToTable("GenreLinks"); + }); + + modelBuilder.Entity("Flaro.Core.Library.MediaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BitRate") + .HasColumnType("INTEGER"); + + b.Property("Container") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Duration") + .HasColumnType("INTEGER"); + + b.Property("EpisodeId") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("TEXT"); + + b.Property("ModifiedAt") + .HasColumnType("INTEGER"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("ProbedAt") + .HasColumnType("INTEGER"); + + b.Property("SeenAt") + .HasColumnType("INTEGER"); + + b.Property("SizeBytes") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId"); + + b.HasIndex("MovieId"); + + b.HasIndex("LibraryId", "Path") + .IsUnique(); + + b.ToTable("MediaFiles"); + }); + + modelBuilder.Entity("Flaro.Core.Library.MediaStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Channels") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("IsForced") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Language") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("MediaFileId") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("MediaFileId", "Index") + .IsUnique(); + + b.ToTable("MediaStreams"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Movie", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedAt") + .HasColumnType("INTEGER"); + + b.Property("BackdropImage") + .HasColumnType("TEXT"); + + b.Property("ContentRating") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("ImdbId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("TEXT"); + + b.Property("LogoImage") + .HasColumnType("TEXT"); + + b.Property("MatchLocked") + .HasColumnType("INTEGER"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("ParsedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ParsedYear") + .HasColumnType("INTEGER"); + + b.Property("PosterImage") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("RuntimeMinutes") + .HasColumnType("INTEGER"); + + b.Property("SortTitleValue") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TmdbId") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId", "SortTitleValue"); + + b.ToTable("Movies"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Image") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("TmdbId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TmdbId") + .IsUnique(); + + b.ToTable("People"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedAt") + .HasColumnType("INTEGER"); + + b.Property("BackdropImage") + .HasColumnType("TEXT"); + + b.Property("ContentRating") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("ImdbId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("TEXT"); + + b.Property("LogoImage") + .HasColumnType("TEXT"); + + b.Property("MatchLocked") + .HasColumnType("INTEGER"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("ParsedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ParsedYear") + .HasColumnType("INTEGER"); + + b.Property("PosterImage") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("RuntimeMinutes") + .HasColumnType("INTEGER"); + + b.Property("SortTitleValue") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TmdbId") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId", "SortTitleValue"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Dismissed") + .HasColumnType("INTEGER"); + + b.Property("EpisodeId") + .HasColumnType("TEXT"); + + b.Property("Favourite") + .HasColumnType("INTEGER"); + + b.Property("InProgress") + .HasColumnType("INTEGER"); + + b.Property("LastPlayedAt") + .HasColumnType("INTEGER"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("PlayCount") + .HasColumnType("INTEGER"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Runtime") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Watched") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId"); + + b.HasIndex("MovieId"); + + b.HasIndex("UserId", "EpisodeId") + .IsUnique() + .HasFilter("\"EpisodeId\" IS NOT NULL"); + + b.HasIndex("UserId", "MovieId") + .IsUnique() + .HasFilter("\"MovieId\" IS NOT NULL"); + + b.HasIndex("UserId", "InProgress", "UpdatedAt"); + + b.ToTable("UserItemStates"); + }); + + modelBuilder.Entity("Flaro.Core.ServerSetting", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("Settings"); + }); + + modelBuilder.Entity("Flaro.Core.Users.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("IsAdministrator") + .HasColumnType("INTEGER"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.HasKey("Id"); + + b.HasIndex("UserName") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Flaro.Core.Auth.DeviceToken", b => + { + b.HasOne("Flaro.Core.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Library.Credit", b => + { + b.HasOne("Flaro.Core.Library.Movie", null) + .WithMany() + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Library.Person", null) + .WithMany() + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Flaro.Core.Library.Series", null) + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Flaro.Core.Library.Episode", b => + { + b.HasOne("Flaro.Core.Library.Series", null) + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Library.GenreLink", b => + { + b.HasOne("Flaro.Core.Library.Genre", null) + .WithMany() + .HasForeignKey("GenreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Flaro.Core.Library.Movie", null) + .WithMany() + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Library.Series", null) + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Flaro.Core.Library.MediaFile", b => + { + b.HasOne("Flaro.Core.Libraries.Library", null) + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Library.MediaStream", b => + { + b.HasOne("Flaro.Core.Library.MediaFile", null) + .WithMany() + .HasForeignKey("MediaFileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Library.Movie", b => + { + b.HasOne("Flaro.Core.Libraries.Library", null) + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Library.Series", b => + { + b.HasOne("Flaro.Core.Libraries.Library", null) + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b => + { + b.HasOne("Flaro.Core.Library.Episode", null) + .WithMany() + .HasForeignKey("EpisodeId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Library.Movie", null) + .WithMany() + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/server/src/Flaro.Data/Migrations/20261003210324_Katalog.cs b/server/src/Flaro.Data/Migrations/20261003210324_Katalog.cs new file mode 100644 index 0000000..531e7df --- /dev/null +++ b/server/src/Flaro.Data/Migrations/20261003210324_Katalog.cs @@ -0,0 +1,279 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Flaro.Data.Migrations +{ + /// + public partial class Katalog : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ContentRating", + table: "Series", + type: "TEXT", + maxLength: 16, + nullable: true); + + migrationBuilder.AddColumn( + name: "LogoImage", + table: "Series", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "Rating", + table: "Series", + type: "REAL", + nullable: true); + + migrationBuilder.AddColumn( + name: "RuntimeMinutes", + table: "Series", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "Tagline", + table: "Series", + type: "TEXT", + maxLength: 512, + nullable: true); + + migrationBuilder.AddColumn( + name: "ContentRating", + table: "Movies", + type: "TEXT", + maxLength: 16, + nullable: true); + + migrationBuilder.AddColumn( + name: "LogoImage", + table: "Movies", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "Rating", + table: "Movies", + type: "REAL", + nullable: true); + + migrationBuilder.AddColumn( + name: "RuntimeMinutes", + table: "Movies", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "Tagline", + table: "Movies", + type: "TEXT", + maxLength: 512, + nullable: true); + + migrationBuilder.CreateTable( + name: "Genres", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 64, nullable: false, collation: "NOCASE") + }, + constraints: table => + { + table.PrimaryKey("PK_Genres", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "People", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 256, nullable: false), + TmdbId = table.Column(type: "INTEGER", nullable: false), + Image = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_People", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "GenreLinks", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + GenreId = table.Column(type: "TEXT", nullable: false), + MovieId = table.Column(type: "TEXT", nullable: true), + SeriesId = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_GenreLinks", x => x.Id); + table.ForeignKey( + name: "FK_GenreLinks_Genres_GenreId", + column: x => x.GenreId, + principalTable: "Genres", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_GenreLinks_Movies_MovieId", + column: x => x.MovieId, + principalTable: "Movies", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_GenreLinks_Series_SeriesId", + column: x => x.SeriesId, + principalTable: "Series", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Credits", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + PersonId = table.Column(type: "TEXT", nullable: false), + MovieId = table.Column(type: "TEXT", nullable: true), + SeriesId = table.Column(type: "TEXT", nullable: true), + Role = table.Column(type: "TEXT", maxLength: 16, nullable: false), + Character = table.Column(type: "TEXT", maxLength: 256, nullable: true), + Order = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Credits", x => x.Id); + table.ForeignKey( + name: "FK_Credits_Movies_MovieId", + column: x => x.MovieId, + principalTable: "Movies", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_Credits_People_PersonId", + column: x => x.PersonId, + principalTable: "People", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_Credits_Series_SeriesId", + column: x => x.SeriesId, + principalTable: "Series", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Credits_MovieId", + table: "Credits", + column: "MovieId"); + + migrationBuilder.CreateIndex( + name: "IX_Credits_PersonId", + table: "Credits", + column: "PersonId"); + + migrationBuilder.CreateIndex( + name: "IX_Credits_SeriesId", + table: "Credits", + column: "SeriesId"); + + migrationBuilder.CreateIndex( + name: "IX_GenreLinks_GenreId_MovieId", + table: "GenreLinks", + columns: new[] { "GenreId", "MovieId" }, + unique: true, + filter: "\"MovieId\" IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_GenreLinks_GenreId_SeriesId", + table: "GenreLinks", + columns: new[] { "GenreId", "SeriesId" }, + unique: true, + filter: "\"SeriesId\" IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_GenreLinks_MovieId", + table: "GenreLinks", + column: "MovieId"); + + migrationBuilder.CreateIndex( + name: "IX_GenreLinks_SeriesId", + table: "GenreLinks", + column: "SeriesId"); + + migrationBuilder.CreateIndex( + name: "IX_Genres_Name", + table: "Genres", + column: "Name", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_People_TmdbId", + table: "People", + column: "TmdbId", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Credits"); + + migrationBuilder.DropTable( + name: "GenreLinks"); + + migrationBuilder.DropTable( + name: "People"); + + migrationBuilder.DropTable( + name: "Genres"); + + migrationBuilder.DropColumn( + name: "ContentRating", + table: "Series"); + + migrationBuilder.DropColumn( + name: "LogoImage", + table: "Series"); + + migrationBuilder.DropColumn( + name: "Rating", + table: "Series"); + + migrationBuilder.DropColumn( + name: "RuntimeMinutes", + table: "Series"); + + migrationBuilder.DropColumn( + name: "Tagline", + table: "Series"); + + migrationBuilder.DropColumn( + name: "ContentRating", + table: "Movies"); + + migrationBuilder.DropColumn( + name: "LogoImage", + table: "Movies"); + + migrationBuilder.DropColumn( + name: "Rating", + table: "Movies"); + + migrationBuilder.DropColumn( + name: "RuntimeMinutes", + table: "Movies"); + + migrationBuilder.DropColumn( + name: "Tagline", + table: "Movies"); + } + } +} diff --git a/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs b/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs index 2ee792f..34e8ecc 100644 --- a/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs +++ b/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs @@ -137,6 +137,44 @@ namespace Flaro.Data.Migrations b.ToTable("Libraries"); }); + modelBuilder.Entity("Flaro.Core.Library.Credit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Character") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MovieId"); + + b.HasIndex("PersonId"); + + b.HasIndex("SeriesId"); + + b.ToTable("Credits"); + }); + modelBuilder.Entity("Flaro.Core.Library.Episode", b => { b.Property("Id") @@ -173,6 +211,58 @@ namespace Flaro.Data.Migrations b.ToTable("Episodes"); }); + modelBuilder.Entity("Flaro.Core.Library.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Genres"); + }); + + modelBuilder.Entity("Flaro.Core.Library.GenreLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("GenreId") + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MovieId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("GenreId", "MovieId") + .IsUnique() + .HasFilter("\"MovieId\" IS NOT NULL"); + + b.HasIndex("GenreId", "SeriesId") + .IsUnique() + .HasFilter("\"SeriesId\" IS NOT NULL"); + + b.ToTable("GenreLinks"); + }); + modelBuilder.Entity("Flaro.Core.Library.MediaFile", b => { b.Property("Id") @@ -291,6 +381,10 @@ namespace Flaro.Data.Migrations b.Property("BackdropImage") .HasColumnType("TEXT"); + b.Property("ContentRating") + .HasMaxLength(16) + .HasColumnType("TEXT"); + b.Property("ImdbId") .HasMaxLength(32) .HasColumnType("TEXT"); @@ -298,6 +392,9 @@ namespace Flaro.Data.Migrations b.Property("LibraryId") .HasColumnType("TEXT"); + b.Property("LogoImage") + .HasColumnType("TEXT"); + b.Property("MatchLocked") .HasColumnType("INTEGER"); @@ -314,11 +411,21 @@ namespace Flaro.Data.Migrations b.Property("PosterImage") .HasColumnType("TEXT"); + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("RuntimeMinutes") + .HasColumnType("INTEGER"); + b.Property("SortTitleValue") .IsRequired() .HasMaxLength(512) .HasColumnType("TEXT"); + b.Property("Tagline") + .HasMaxLength(512) + .HasColumnType("TEXT"); + b.Property("Title") .IsRequired() .HasMaxLength(512) @@ -337,6 +444,31 @@ namespace Flaro.Data.Migrations b.ToTable("Movies"); }); + modelBuilder.Entity("Flaro.Core.Library.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Image") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("TmdbId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TmdbId") + .IsUnique(); + + b.ToTable("People"); + }); + modelBuilder.Entity("Flaro.Core.Library.Series", b => { b.Property("Id") @@ -349,6 +481,10 @@ namespace Flaro.Data.Migrations b.Property("BackdropImage") .HasColumnType("TEXT"); + b.Property("ContentRating") + .HasMaxLength(16) + .HasColumnType("TEXT"); + b.Property("ImdbId") .HasMaxLength(32) .HasColumnType("TEXT"); @@ -356,6 +492,9 @@ namespace Flaro.Data.Migrations b.Property("LibraryId") .HasColumnType("TEXT"); + b.Property("LogoImage") + .HasColumnType("TEXT"); + b.Property("MatchLocked") .HasColumnType("INTEGER"); @@ -372,11 +511,21 @@ namespace Flaro.Data.Migrations b.Property("PosterImage") .HasColumnType("TEXT"); + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("RuntimeMinutes") + .HasColumnType("INTEGER"); + b.Property("SortTitleValue") .IsRequired() .HasMaxLength(512) .HasColumnType("TEXT"); + b.Property("Tagline") + .HasMaxLength(512) + .HasColumnType("TEXT"); + b.Property("Title") .IsRequired() .HasMaxLength(512) @@ -515,6 +664,25 @@ namespace Flaro.Data.Migrations .IsRequired(); }); + modelBuilder.Entity("Flaro.Core.Library.Credit", b => + { + b.HasOne("Flaro.Core.Library.Movie", null) + .WithMany() + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Library.Person", null) + .WithMany() + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Flaro.Core.Library.Series", null) + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade); + }); + modelBuilder.Entity("Flaro.Core.Library.Episode", b => { b.HasOne("Flaro.Core.Library.Series", null) @@ -524,6 +692,25 @@ namespace Flaro.Data.Migrations .IsRequired(); }); + modelBuilder.Entity("Flaro.Core.Library.GenreLink", b => + { + b.HasOne("Flaro.Core.Library.Genre", null) + .WithMany() + .HasForeignKey("GenreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Flaro.Core.Library.Movie", null) + .WithMany() + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Library.Series", null) + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade); + }); + modelBuilder.Entity("Flaro.Core.Library.MediaFile", b => { b.HasOne("Flaro.Core.Libraries.Library", null) diff --git a/server/src/Flaro.Metadata/TmdbClient.cs b/server/src/Flaro.Metadata/TmdbClient.cs index 04041a3..09f2d7c 100644 --- a/server/src/Flaro.Metadata/TmdbClient.cs +++ b/server/src/Flaro.Metadata/TmdbClient.cs @@ -38,6 +38,19 @@ public sealed class TmdbClient( private const string PosterSize = "w500"; private const string BackdropSize = "w1280"; private const string StillSize = "w300"; + /// Porträts sind klein und viele — ein Kopf je Kachel. + private const string ProfileSize = "w185"; + /// Schriftzüge sind breit und flach — w500 reicht auch gross. + private const string LogoSize = "w500"; + + /// Wie viele Darsteller je Titel übernommen werden. + /// + /// + /// TMDB nennt bei grossen Produktionen über hundert. Eine Besetzungsliste, + /// die man scrollen muss, bis die Statisten kommen, hilft niemandem — und + /// jede Zeile ist ein Porträt, das heruntergeladen und abgelegt wird. + /// + private const int HöchstensDarsteller = 20; private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); @@ -58,7 +71,13 @@ public sealed class TmdbClient( { var treffer = await SuchenAsync("search/movie", title, year is { } j ? ("year", j.ToString()) : null, m => m.Title, ct); - return treffer is null ? null : await MitImdb(Als(treffer, title), "movie", ct); + if (treffer is null) return null; + + // **Der Treffer der Suche taugt nur als Kennung.** `search/movie` + // liefert weder Laufzeit noch Genres noch Besetzung; dafür braucht es + // den Einzelabruf. Der kostet eine Anfrage — dieselbe, die vorher + // ohnehin für die IMDb-Kennung fällig war. + return await GetMovieAsync(treffer.Id, ct) ?? Als(treffer, title); } public async Task> SearchMoviesAsync( @@ -74,9 +93,36 @@ public sealed class TmdbClient( var einstellung = await einstellungen.GetAsync(ct); if (string.IsNullOrWhiteSpace(einstellung.ApiKey)) return null; + // **Alles in einer Anfrage.** `append_to_response` hängt Besetzung, + // Fremdkennungen und Freigaben an den Einzelabruf. Drei getrennte + // Anfragen je Titel wären bei vierhundert Filmen zwölfhundert — und + // TMDB begrenzt, wie oft man fragen darf. var film = await HolenAsync( - $"movie/{providerId}?language={Uri.EscapeDataString(einstellung.Language)}", einstellung, ct); - return film is null ? null : await MitImdb(Als(film, ""), "movie", ct); + $"movie/{providerId}?language={Uri.EscapeDataString(einstellung.Language)}" + + "&append_to_response=credits,external_ids,release_dates,images" + + // **Sprachlos ist erwünscht.** Viele Schriftzüge tragen gar keine + // Sprache (`null`), weil sie keinen Text enthalten, der übersetzt + // werden müsste. Ohne diese Angabe liefert TMDB sie nicht mit. + $"&include_image_language={Uri.EscapeDataString(Sprachkürzel(einstellung.Language))},en,null", + einstellung, ct); + + // **Kennung null gibt es bei TMDB nicht.** Antwortet etwas anderes + // als der erwartete Einzelabruf — ein Anmeldeportal, ein Proxy, eine + // Trefferliste —, liest der Dekoder daraus eine Null. Das als Treffer + // durchzulassen hiesse, einen Film mit der Kennung 0 anzulegen. + if (film is null or { Id: 0 }) return null; + + return Als(film, "") with + { + ImdbId = Leer(film.ExternalIds?.ImdbId), + RuntimeMinutes = film.Runtime > 0 ? film.Runtime : null, + Rating = film.VoteAverage > 0 ? film.VoteAverage : null, + Tagline = Leer(film.Tagline), + ContentRating = Freigabe(film.ReleaseDates, Land(einstellung.Language)), + Genres = Namen(film.Genres), + Credits = Mitwirkende(film.Credits), + Logo = Schriftzug(film.Images, Sprachkürzel(einstellung.Language)), + }; } private static MovieMetadata Als(TmdbMovie roh, string rückfall) => new( @@ -87,7 +133,8 @@ public sealed class TmdbClient( { var treffer = await SuchenAsync("search/tv", title, year is { } j ? ("first_air_date_year", j.ToString()) : null, s => s.Name, ct); - return treffer is null ? null : await MitImdb(Als(treffer, title), "tv", ct); + if (treffer is null) return null; + return await GetSeriesAsync(treffer.Id, ct) ?? Als(treffer, title); } public async Task> SearchSeriesAsync( @@ -104,39 +151,34 @@ public sealed class TmdbClient( if (string.IsNullOrWhiteSpace(einstellung.ApiKey)) return null; var serie = await HolenAsync( - $"tv/{providerId}?language={Uri.EscapeDataString(einstellung.Language)}", einstellung, ct); - return serie is null ? null : await MitImdb(Als(serie, ""), "tv", ct); + $"tv/{providerId}?language={Uri.EscapeDataString(einstellung.Language)}" + + "&append_to_response=credits,external_ids,content_ratings,images" + + $"&include_image_language={Uri.EscapeDataString(Sprachkürzel(einstellung.Language))},en,null", + einstellung, ct); + + if (serie is null or { Id: 0 }) return null; + + return Als(serie, "") with + { + ImdbId = Leer(serie.ExternalIds?.ImdbId), + // Die übliche Länge einer Folge, nicht die der ganzen Serie. + RuntimeMinutes = serie.EpisodeRunTime?.FirstOrDefault(l => l > 0), + Rating = serie.VoteAverage > 0 ? serie.VoteAverage : null, + Tagline = Leer(serie.Tagline), + ContentRating = Freigabe(serie.ContentRatings, Land(einstellung.Language)), + Genres = Namen(serie.Genres), + Credits = Mitwirkende(serie.Credits), + Logo = Schriftzug(serie.Images, Sprachkürzel(einstellung.Language)), + }; } private static SeriesMetadata Als(TmdbSeries roh, string rückfall) => new( roh.Id, roh.Name ?? rückfall, JahrAus(roh.FirstAirDate), Leer(roh.Overview), Bild(roh.PosterPath, PosterSize), Bild(roh.BackdropPath, BackdropSize)); - /// Holt die IMDb-Kennung nach — eine Anfrage je neuem Treffer. - /// - /// - /// Die Suche bei TMDB liefert sie nicht mit. Gebraucht wird sie für - /// die NFO-Datei: wer später auf ein anderes Werkzeug wechselt, findet - /// damit denselben Titel wieder, auch wenn das Werkzeug TMDB gar nicht - /// kennt. - /// - /// Scheitert der Nachschlag, bleibt sie leer — das ist kein Grund, den - /// ganzen Treffer wegzuwerfen. - /// - private async Task MitImdb(MovieMetadata film, string art, CancellationToken ct) => - film with { ImdbId = await ImdbAsync(art, film.ProviderId, ct) }; - - private async Task MitImdb(SeriesMetadata serie, string art, CancellationToken ct) => - serie with { ImdbId = await ImdbAsync(art, serie.ProviderId, ct) }; - - private async Task ImdbAsync(string art, int kennung, CancellationToken ct) - { - var einstellung = await einstellungen.GetAsync(ct); - if (string.IsNullOrWhiteSpace(einstellung.ApiKey)) return null; - - var fremde = await HolenAsync($"{art}/{kennung}/external_ids", einstellung, ct); - return Leer(fremde?.ImdbId); - } + // Der eigene Abruf von `external_ids` ist entfallen: die Kennung hängt + // jetzt über `append_to_response` am Einzelabruf. Das spart eine Anfrage + // je Titel — bei vierhundert Filmen vierhundert. public async Task FindEpisodeAsync( int seriesProviderId, int season, int episode, CancellationToken ct = default) @@ -261,6 +303,115 @@ public sealed class TmdbClient( return index < 0 ? pfadMitAbfrage : pfadMitAbfrage[..index] + "api_key=…"; } + /// Das Land aus einer Sprachangabe wie „de-DE". + /// + /// + /// Freigaben sind national: die FSK gilt in Deutschland, die MPAA in den + /// USA, und TMDB führt beide nebeneinander. Steht kein Land dabei, ist + /// „US" die Angabe, die am häufigsten gefüllt ist — besser als keine. + /// + private static string Land(string sprache) + { + var teile = sprache.Split('-'); + return teile.Length > 1 && teile[1].Length == 2 ? teile[1].ToUpperInvariant() : "US"; + } + + /// „de" aus „de-DE". + private static string Sprachkürzel(string sprache) => + sprache.Split('-')[0].ToLowerInvariant(); + + /// Der Schriftzug, möglichst in der eingestellten Sprache. + /// + /// + /// Die Reihenfolge ist die Aussage. Erst die eigene Sprache — ein + /// deutscher Filmtitel in deutscher Schrift —, dann der ohne Sprache + /// (meist ein reines Bildzeichen, überall richtig), dann Englisch. Erst + /// innerhalb einer Stufe entscheidet die Bewertung bei TMDB, weil es dort + /// um Bildqualität geht und nicht mehr um die Sprache. + /// + private static RemoteImage? Schriftzug(TmdbImages? bilder, string sprache) + { + var alle = bilder?.Logos?.Where(l => !string.IsNullOrWhiteSpace(l.FilePath)).ToList(); + if (alle is not { Count: > 0 }) return null; + + var beste = alle + .OrderBy(l => string.Equals(l.Language, sprache, StringComparison.OrdinalIgnoreCase) ? 0 + : string.IsNullOrEmpty(l.Language) ? 1 + : string.Equals(l.Language, "en", StringComparison.OrdinalIgnoreCase) ? 2 + : 3) + .ThenByDescending(l => l.VoteAverage ?? 0) + .First(); + + return Bild(beste.FilePath, LogoSize); + } + + private static string? Freigabe(TmdbReleaseDates? quelle, string land) => + Leer(quelle?.Results? + .FirstOrDefault(r => string.Equals(r.Country, land, StringComparison.OrdinalIgnoreCase))? + .Releases? + .Select(r => r.Certification) + .FirstOrDefault(c => !string.IsNullOrWhiteSpace(c))); + + private static string? Freigabe(TmdbContentRatings? quelle, string land) => + Leer(quelle?.Results? + .FirstOrDefault(r => string.Equals(r.Country, land, StringComparison.OrdinalIgnoreCase))? + .Rating); + + private static IReadOnlyList? Namen(List? genres) + { + var liste = genres? + .Select(g => g.Name?.Trim()) + .Where(n => !string.IsNullOrEmpty(n)) + .Select(n => n!) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + return liste is { Count: > 0 } ? liste : null; + } + + /// Darsteller, Regie und Drehbuch — in dieser Reihenfolge. + /// + /// + /// Aus dem Stab nur zwei Rollen. TMDB führt dort auch Beleuchter + /// und Caterer; eine Detailseite, die alle nennt, nennt nichts. Regie und + /// Drehbuch sind das, wonach jemand sucht. + /// + private static IReadOnlyList? Mitwirkende(TmdbCredits? quelle) + { + if (quelle is null) return null; + + var liste = new List(); + + foreach (var d in (quelle.Cast ?? []).OrderBy(c => c.Order ?? int.MaxValue).Take(HöchstensDarsteller)) + { + if (string.IsNullOrWhiteSpace(d.Name)) continue; + liste.Add(new CreditMetadata( + d.Id, d.Name.Trim(), CreditKind.Actor, Leer(d.Character), + Bild(d.ProfilePath, ProfileSize), d.Order ?? liste.Count)); + } + + var stab = 0; + foreach (var m in quelle.Crew ?? []) + { + var rolle = m.Job?.Trim() switch + { + "Director" => CreditKind.Director, + "Writer" or "Screenplay" or "Story" => CreditKind.Writer, + _ => (CreditKind?)null, + }; + if (rolle is not { } art || string.IsNullOrWhiteSpace(m.Name)) continue; + + // Dieselbe Person steht im Stab mehrfach, wenn sie mehrere + // Aufgaben hatte — „Drehbuch" und „Story" sind bei TMDB zwei + // Zeilen und auf einer Detailseite ein Name. + if (liste.Any(e => e.ProviderId == m.Id && e.Kind == art)) continue; + + liste.Add(new CreditMetadata( + m.Id, m.Name.Trim(), art, null, Bild(m.ProfilePath, ProfileSize), stab++)); + } + + return liste.Count > 0 ? liste : null; + } + private static RemoteImage? Bild(string? pfad, string grösse) => string.IsNullOrWhiteSpace(pfad) ? null : new RemoteImage($"https://image.tmdb.org/t/p/{grösse}{pfad}"); @@ -280,7 +431,16 @@ public sealed class TmdbClient( [property: JsonPropertyName("release_date")] string? ReleaseDate, [property: JsonPropertyName("overview")] string? Overview, [property: JsonPropertyName("poster_path")] string? PosterPath, - [property: JsonPropertyName("backdrop_path")] string? BackdropPath); + [property: JsonPropertyName("backdrop_path")] string? BackdropPath, + // Die folgenden liefert nur der Einzelabruf, nicht die Suche. + [property: JsonPropertyName("runtime")] int? Runtime = null, + [property: JsonPropertyName("vote_average")] double? VoteAverage = null, + [property: JsonPropertyName("tagline")] string? Tagline = null, + [property: JsonPropertyName("genres")] List? Genres = null, + [property: JsonPropertyName("credits")] TmdbCredits? Credits = null, + [property: JsonPropertyName("external_ids")] TmdbExternalIds? ExternalIds = null, + [property: JsonPropertyName("release_dates")] TmdbReleaseDates? ReleaseDates = null, + [property: JsonPropertyName("images")] TmdbImages? Images = null); private sealed record TmdbSeries( [property: JsonPropertyName("id")] int Id, @@ -288,7 +448,59 @@ public sealed class TmdbClient( [property: JsonPropertyName("first_air_date")] string? FirstAirDate, [property: JsonPropertyName("overview")] string? Overview, [property: JsonPropertyName("poster_path")] string? PosterPath, - [property: JsonPropertyName("backdrop_path")] string? BackdropPath); + [property: JsonPropertyName("backdrop_path")] string? BackdropPath, + [property: JsonPropertyName("episode_run_time")] List? EpisodeRunTime = null, + [property: JsonPropertyName("vote_average")] double? VoteAverage = null, + [property: JsonPropertyName("tagline")] string? Tagline = null, + [property: JsonPropertyName("genres")] List? Genres = null, + [property: JsonPropertyName("credits")] TmdbCredits? Credits = null, + [property: JsonPropertyName("external_ids")] TmdbExternalIds? ExternalIds = null, + [property: JsonPropertyName("content_ratings")] TmdbContentRatings? ContentRatings = null, + [property: JsonPropertyName("images")] TmdbImages? Images = null); + + private sealed record TmdbGenre([property: JsonPropertyName("name")] string? Name); + + private sealed record TmdbImages( + [property: JsonPropertyName("logos")] List? Logos); + + private sealed record TmdbImage( + [property: JsonPropertyName("file_path")] string? FilePath, + [property: JsonPropertyName("iso_639_1")] string? Language, + [property: JsonPropertyName("vote_average")] double? VoteAverage); + + private sealed record TmdbCredits( + [property: JsonPropertyName("cast")] List? Cast, + [property: JsonPropertyName("crew")] List? Crew); + + private sealed record TmdbCast( + [property: JsonPropertyName("id")] int Id, + [property: JsonPropertyName("name")] string? Name, + [property: JsonPropertyName("character")] string? Character, + [property: JsonPropertyName("profile_path")] string? ProfilePath, + [property: JsonPropertyName("order")] int? Order); + + private sealed record TmdbCrew( + [property: JsonPropertyName("id")] int Id, + [property: JsonPropertyName("name")] string? Name, + [property: JsonPropertyName("job")] string? Job, + [property: JsonPropertyName("profile_path")] string? ProfilePath); + + private sealed record TmdbReleaseDates( + [property: JsonPropertyName("results")] List? Results); + + private sealed record TmdbReleaseCountry( + [property: JsonPropertyName("iso_3166_1")] string? Country, + [property: JsonPropertyName("release_dates")] List? Releases); + + private sealed record TmdbRelease( + [property: JsonPropertyName("certification")] string? Certification); + + private sealed record TmdbContentRatings( + [property: JsonPropertyName("results")] List? Results); + + private sealed record TmdbContentRating( + [property: JsonPropertyName("iso_3166_1")] string? Country, + [property: JsonPropertyName("rating")] string? Rating); private sealed record TmdbEpisode( [property: JsonPropertyName("name")] string? Name, diff --git a/server/tests/Flaro.Metadata.Tests/AnreicherungTests.cs b/server/tests/Flaro.Metadata.Tests/AnreicherungTests.cs new file mode 100644 index 0000000..17a484c --- /dev/null +++ b/server/tests/Flaro.Metadata.Tests/AnreicherungTests.cs @@ -0,0 +1,219 @@ +using Flaro.Core.Metadata; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Flaro.Metadata.Tests; + +/// Was ausser Titel und Plakat noch ankommt. +/// +/// +/// Daran hängen Detailseite, Vorschläge und der Filter nach Genre. Mit +/// Titel, Jahr, Beschreibung und zwei Bildern lässt sich eine Kachel zeichnen +/// und sonst nichts. +/// +public class AnreicherungTests +{ + private static TmdbClient Client(Attrappe attrappe, string sprache = "de-DE") => + new(new HttpClient(attrappe) { BaseAddress = new Uri(TmdbClient.BaseAddress) }, + FesteEinstellungen.MitSchluessel(sprache), NullLogger.Instance); + + private const string Suche = """{"results":[{"id":603,"title":"Matrix"}]}"""; + + private const string Einzeln = """ + {"id":603,"title":"Matrix","release_date":"1999-03-30", + "overview":"Neo zweifelt an der Wirklichkeit.", + "poster_path":"/abc.jpg","backdrop_path":"/def.jpg", + "runtime":136,"vote_average":8.2,"tagline":"Die Wirklichkeit ist eine Lüge.", + "genres":[{"name":"Science Fiction"},{"name":"Action"}], + "external_ids":{"imdb_id":"tt0133093"}, + "release_dates":{"results":[ + {"iso_3166_1":"US","release_dates":[{"certification":"R"}]}, + {"iso_3166_1":"DE","release_dates":[{"certification":""},{"certification":"16"}]} + ]}, + "images":{"logos":[ + {"file_path":"/englisch.png","iso_639_1":"en","vote_average":9.0}, + {"file_path":"/deutsch.png","iso_639_1":"de","vote_average":2.0}, + {"file_path":"/ohne.png","iso_639_1":null,"vote_average":5.0} + ]}, + "credits":{ + "cast":[ + {"id":6384,"name":"Keanu Reeves","character":"Neo","profile_path":"/k.jpg","order":0}, + {"id":2975,"name":"Laurence Fishburne","character":"Morpheus","profile_path":null,"order":1} + ], + "crew":[ + {"id":9339,"name":"Lana Wachowski","job":"Director","profile_path":"/l.jpg"}, + {"id":9339,"name":"Lana Wachowski","job":"Writer","profile_path":"/l.jpg"}, + {"id":9339,"name":"Lana Wachowski","job":"Story","profile_path":"/l.jpg"}, + {"id":111,"name":"Jemand vom Licht","job":"Gaffer","profile_path":null} + ] + }} + """; + + private static Attrappe Vollstaendig() => + Attrappe.NachPfad(("search/movie", Suche), ("movie/603", Einzeln)); + + [Test] + public async Task Laufzeit_Bewertung_und_Werbezeile_kommen_an() + { + var film = await Client(Vollstaendig()).FindMovieAsync("Matrix", 1999); + + Assert.Multiple(() => + { + Assert.That(film!.ProviderId, Is.EqualTo(603)); + Assert.That(film.RuntimeMinutes, Is.EqualTo(136)); + Assert.That(film.Rating, Is.EqualTo(8.2)); + Assert.That(film.Tagline, Is.EqualTo("Die Wirklichkeit ist eine Lüge.")); + Assert.That(film.ImdbId, Is.EqualTo("tt0133093")); + }); + } + + [Test] + public async Task Genres_kommen_als_Namen_an() + { + var film = await Client(Vollstaendig()).FindMovieAsync("Matrix", 1999); + Assert.That(film!.Genres, Is.EqualTo(new[] { "Science Fiction", "Action" })); + } + + /// Freigaben sind national — die FSK gilt nicht in Kansas. + [Test] + public async Task Die_Freigabe_richtet_sich_nach_dem_eingestellten_Land() + { + var deutsch = await Client(Vollstaendig(), "de-DE").FindMovieAsync("Matrix", 1999); + var amerikanisch = await Client(Vollstaendig(), "en-US").FindMovieAsync("Matrix", 1999); + + Assert.Multiple(() => + { + // Der erste DE-Eintrag ist leer; genommen wird der erste gefüllte. + Assert.That(deutsch!.ContentRating, Is.EqualTo("16")); + Assert.That(amerikanisch!.ContentRating, Is.EqualTo("R")); + }); + } + + /// Der Schriftzug in der eigenen Sprache schlägt den besser bewerteten. + [Test] + public async Task Das_Logo_kommt_in_der_eingestellten_Sprache() + { + var film = await Client(Vollstaendig(), "de-DE").FindMovieAsync("Matrix", 1999); + + Assert.That(film!.Logo!.Url, Does.EndWith("/deutsch.png"), + "auch wenn das englische bei TMDB besser bewertet ist"); + } + + /// Ohne eigene Sprache gewinnt das sprachlose vor dem englischen. + /// + /// + /// Ein Schriftzug ohne Sprachangabe enthält meist gar keinen Text — + /// er ist überall richtig. + /// + [Test] + public async Task Ohne_passende_Sprache_gewinnt_das_sprachlose() + { + var film = await Client(Vollstaendig(), "fr-FR").FindMovieAsync("Matrix", 1999); + Assert.That(film!.Logo!.Url, Does.EndWith("/ohne.png")); + } + + [Test] + public async Task Darsteller_kommen_in_der_Reihenfolge_der_Quelle() + { + var film = await Client(Vollstaendig()).FindMovieAsync("Matrix", 1999); + var darsteller = film!.Credits!.Where(c => c.Kind == CreditKind.Actor).ToList(); + + Assert.Multiple(() => + { + Assert.That(darsteller.Select(d => d.Name), + Is.EqualTo(new[] { "Keanu Reeves", "Laurence Fishburne" })); + Assert.That(darsteller[0].Character, Is.EqualTo("Neo")); + Assert.That(darsteller[0].Portrait!.Url, Does.EndWith("/k.jpg")); + Assert.That(darsteller[1].Portrait, Is.Null, "kein Bild ist null, keine kaputte Adresse"); + }); + } + + /// Aus dem Stab nur Regie und Drehbuch. + /// + /// + /// TMDB führt dort auch Beleuchter und Caterer. Eine Detailseite, die + /// alle nennt, nennt nichts. + /// + [Test] + public async Task Vom_Stab_kommen_nur_Regie_und_Drehbuch() + { + var film = await Client(Vollstaendig()).FindMovieAsync("Matrix", 1999); + var stab = film!.Credits!.Where(c => c.Kind != CreditKind.Actor).ToList(); + + Assert.Multiple(() => + { + Assert.That(stab.Count(c => c.Kind == CreditKind.Director), Is.EqualTo(1)); + // „Writer" und „Story" sind bei TMDB zwei Zeilen und hier ein Name. + Assert.That(stab.Count(c => c.Kind == CreditKind.Writer), Is.EqualTo(1)); + Assert.That(stab.Any(c => c.Name.Contains("Licht")), Is.False); + }); + } + + /// Was ein Einzelabruf nicht hergibt, darf den Treffer nicht kippen. + /// + /// + /// Eine Antwort, die keine Kennung enthält — ein Anmeldeportal, ein Proxy, + /// eine Trefferliste —, liest der Dekoder als Null. Das als Treffer + /// durchzulassen hiesse, einen Film mit der Kennung 0 anzulegen; statt + /// dessen bleibt, was die Suche schon wusste. + /// + [Test] + public async Task Ein_leerer_Einzelabruf_faellt_auf_den_Suchtreffer_zurueck() + { + var attrappe = Attrappe.NachPfad( + ("search/movie", """{"results":[{"id":603,"title":"Matrix","release_date":"1999-03-30"}]}"""), + ("movie/603", "{}")); + + var film = await Client(attrappe).FindMovieAsync("Matrix", 1999); + + Assert.Multiple(() => + { + Assert.That(film!.ProviderId, Is.EqualTo(603)); + Assert.That(film.Title, Is.EqualTo("Matrix")); + Assert.That(film.Genres, Is.Null); + }); + } + + /// Alles in einer Anfrage statt in vieren. + [Test] + public async Task Der_Einzelabruf_holt_alles_auf_einmal() + { + var attrappe = Vollstaendig(); + await Client(attrappe).FindMovieAsync("Matrix", 1999); + + var einzeln = attrappe.Aufrufe.Single(a => a.Contains("movie/603")); + + Assert.Multiple(() => + { + Assert.That(einzeln, Does.Contain("credits")); + Assert.That(einzeln, Does.Contain("external_ids")); + Assert.That(einzeln, Does.Contain("release_dates")); + Assert.That(einzeln, Does.Contain("images")); + // Sprachlose Schriftzüge liefert TMDB nur, wenn man sie nennt. + Assert.That(einzeln, Does.Contain("null")); + Assert.That(attrappe.Aufrufe, Has.Count.EqualTo(2), "Suche und Einzelabruf, mehr nicht"); + }); + } + + [Test] + public async Task Eine_Serie_bekommt_die_Folgenlaenge_als_Laufzeit() + { + var attrappe = Attrappe.NachPfad( + ("search/tv", """{"results":[{"id":70523,"name":"Dark"}]}"""), + ("tv/70523", """ + {"id":70523,"name":"Dark","first_air_date":"2017-12-01", + "episode_run_time":[60],"vote_average":8.4, + "genres":[{"name":"Mystery"}], + "content_ratings":{"results":[{"iso_3166_1":"DE","rating":"16"}]}} + """)); + + var serie = await Client(attrappe).FindSeriesAsync("Dark", null); + + Assert.Multiple(() => + { + Assert.That(serie!.RuntimeMinutes, Is.EqualTo(60)); + Assert.That(serie.Rating, Is.EqualTo(8.4)); + Assert.That(serie.Genres, Is.EqualTo(new[] { "Mystery" })); + Assert.That(serie.ContentRating, Is.EqualTo("16")); + }); + } +} diff --git a/server/tests/Flaro.Metadata.Tests/Attrappe.cs b/server/tests/Flaro.Metadata.Tests/Attrappe.cs index 25489c0..d331070 100644 --- a/server/tests/Flaro.Metadata.Tests/Attrappe.cs +++ b/server/tests/Flaro.Metadata.Tests/Attrappe.cs @@ -34,6 +34,28 @@ public sealed class Attrappe(Func antwo Content = new StringContent(json, Encoding.UTF8, "application/json"), }); + /// Antwortet je nach Pfad verschieden. + /// + /// + /// Nötig, seit ein Treffer zwei Anfragen kostet. Die Suche liefert + /// nur eine Kennung; Laufzeit, Genres, Besetzung und Schriftzug stehen + /// erst im Einzelabruf. Eine Attrappe mit einer Antwort für alles gäbe + /// dem Einzelabruf die Trefferliste zurück — und der läse daraus eine + /// Kennung von null. + /// + /// Der erste Eintrag, dessen Schlüssel im Pfad vorkommt, gewinnt. + /// + public static Attrappe NachPfad(params (string Teil, string Json)[] antworten) => + new(anfrage => + { + var pfad = anfrage.RequestUri!.AbsoluteUri; + var treffer = antworten.FirstOrDefault(a => pfad.Contains(a.Teil, StringComparison.Ordinal)); + return new HttpResponseMessage(treffer.Json is null ? HttpStatusCode.NotFound : HttpStatusCode.OK) + { + Content = new StringContent(treffer.Json ?? "{}", Encoding.UTF8, "application/json"), + }; + }); + public static Attrappe MitStatus(HttpStatusCode code) => new(_ => new HttpResponseMessage(code)); public static Attrappe DieWirft(Exception fehler) => new(_ => throw fehler);