diff --git a/.gitignore b/.gitignore index 1a7f126..7c0035e 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,6 @@ node_modules/ # .NET server/**/bin/ server/**/obj/ -server/data/ -server/cache/ -server/transcode/ +server/**/data/ +server/**/cache/ +server/**/transcode/ diff --git a/server/Halo.slnx b/server/Halo.slnx index 021941a..26dcd7e 100644 --- a/server/Halo.slnx +++ b/server/Halo.slnx @@ -3,9 +3,11 @@ + + diff --git a/server/README.md b/server/README.md index 6c27156..3788e03 100644 --- a/server/README.md +++ b/server/README.md @@ -3,8 +3,9 @@ Ein Medienserver, der dieselben Dinge kann wie Jellyfin — und zwei, die dort nicht gehen. -**Stand: Stufe 1 von 5.** Es gibt Benutzer, Anmeldung, Einrichtung und das -Gerüst. Einen Film spielt er noch nicht ab. Der Entwurf samt Begründungen steht +**Stand: Stufe 2 von 5.** Benutzer, Anmeldung, Einrichtung, und er liest den +Bestand: Dateien finden, Namen erkennen, Metadaten und Bilder von TMDB holen. +Abspielen kann er noch nicht — das ist Stufe 3. Der Entwurf samt Begründungen steht in [`docs/superpowers/specs/2026-10-03-halo-server-design.md`](../docs/superpowers/specs/2026-10-03-halo-server-design.md). ## Warum @@ -66,7 +67,7 @@ hilft. ## Entwickeln ```bash -dotnet test # 55 Tests +dotnet test # 180 Tests dotnet run --project src/Halo.Api # auf http://localhost:5000 ``` @@ -90,6 +91,7 @@ Kommando. |---|---| | `Halo.Core` | Domäne: Benutzer, Anmeldung, Bibliotheken. Kennt kein HTTP und keine Datenbank | | `Halo.Data` | EF Core, SQLite, Migrationen | +| `Halo.Metadata` | TMDB und die Bildablage. Hinter einer Schnittstelle, damit alles andere ohne Netz prüfbar bleibt | | `Halo.Api` | Endpunkte, Authentifizierung, Auslieferung der Oberfläche | Was sicherheitsrelevant ist, liegt in `Halo.Core` — dort ist es ohne Server @@ -108,6 +110,35 @@ Ein nachgeschlagenes Token lässt sich **sofort** widerrufen; ein signiertes gilt bis zum Ablauf weiter, und für ein verlorenes Telefon ist das der falsche Satz. In der Datenbank steht nur der Fingerabdruck. +## Bestand einlesen + +``` +POST /api/v1/libraries/{id}/scan stösst an, antwortet sofort mit 202 +GET /api/v1/libraries/{id}/scan Stand: idle | running | enriching | finished | failed +GET /api/v1/movies?search=&skip=&take= +GET /api/v1/series +GET /api/v1/series/{id}/episodes?season= +GET /api/v1/images/{schlüssel} +``` + +**Der Scan läuft im Hintergrund**, nicht in der Anfrage: ein Bestand mit 40 000 +Titeln braucht Minuten, und eine so lange offene Anfrage läuft hinter jedem +Reverse Proxy ins Zeitlimit. Einer je Bibliothek gleichzeitig. + +Zweimal scannen ergibt dasselbe wie einmal. Zugeordnet wird über den Titel +**aus dem Dateinamen** — der angezeigte kommt nach dem ersten Lauf von TMDB +und ändert sich dabei. + +### Metadaten + +Ohne TMDB-Schlüssel läuft der Scan trotzdem durch; die Titel bleiben dann bei +den Dateinamen. Der Schlüssel wird unter `PUT /api/v1/settings/metadata` +hinterlegt und kommt nie wieder heraus — abgefragt wird nur, *ob* einer da ist. + +Bilder werden einmal heruntergeladen und liegen unter `HALO_CACHE`. Die App +holt sie bei Halo, nicht bei TMDB: sonst bräuchte sie dafür Internet, und TMDB +erführe, wer wann was ansieht. + ## Anmeldung am Fernseher Ein Passwort auf der Apple-TV-Fernbedienung zu tippen ist eine Zumutung. diff --git a/server/src/Halo.Api/Endpoints/Contracts.cs b/server/src/Halo.Api/Endpoints/Contracts.cs index 9acbe3e..d161692 100644 --- a/server/src/Halo.Api/Endpoints/Contracts.cs +++ b/server/src/Halo.Api/Endpoints/Contracts.cs @@ -46,16 +46,31 @@ public sealed record ScanStatus( int? Removed, int? Unrecognised, IReadOnlyList? Problems, - string? Error); + string? Error, + MetadataProgress? Metadata); + +/// Was das Nachschlagen bei der Metadatenquelle ergeben hat. +public sealed record MetadataProgress( + int MoviesMatched, int SeriesMatched, int EpisodesMatched, bool Skipped, string? Error); public sealed record MediaFileResponse(Guid Id, string Path, long SizeBytes); public sealed record MovieResponse( - Guid Id, string Title, int? Year, string? Overview, IReadOnlyList Files); + Guid Id, string Title, int? Year, string? Overview, + string? PosterImage, string? BackdropImage, + IReadOnlyList Files); -public sealed record SeriesResponse(Guid Id, string Title, int? Year, int EpisodeCount, IReadOnlyList Seasons); +public sealed record SeriesResponse( + Guid Id, string Title, int? Year, string? Overview, + string? PosterImage, string? BackdropImage, + int EpisodeCount, IReadOnlyList Seasons); public sealed record EpisodeResponse( - Guid Id, int Season, int Episode, string? Title, IReadOnlyList Files); + Guid Id, int Season, int Episode, string? Title, string? Overview, string? StillImage, + IReadOnlyList Files); public sealed record Page(IReadOnlyList Items, int Total); + +public sealed record MetadataSettingsResponse(bool HasApiKey, string Language); + +public sealed record MetadataSettingsRequest(string? ApiKey, string? Language); diff --git a/server/src/Halo.Api/Endpoints/ImageEndpoints.cs b/server/src/Halo.Api/Endpoints/ImageEndpoints.cs new file mode 100644 index 0000000..1d8b566 --- /dev/null +++ b/server/src/Halo.Api/Endpoints/ImageEndpoints.cs @@ -0,0 +1,31 @@ +using Halo.Metadata; +using Microsoft.AspNetCore.Mvc; + +namespace Halo.Api.Endpoints; + +public static class ImageEndpoints +{ + public static void MapImages(this IEndpointRouteBuilder app) + { + app.MapGet("/api/v1/images/{key}", (string key, ImageCache ablage) => + { + // Gibt `null` zurück, sobald der Schlüssel kein reiner Hash ist — + // ohne diese Prüfung wäre die Adresszeile ein Weg in jedes + // Verzeichnis des Containers. + var pfad = ablage.PfadFür(key); + if (pfad is null || !File.Exists(pfad)) return Results.NotFound(); + + // Der Schlüssel ist der Hash des Inhalts, also ändert sich das Bild + // unter dieser Adresse nie. Ein Jahr zwischenspeichern spart bei + // einer Mediathek mit tausend Plakaten das meiste an Verkehr. + return Results.File(pfad, "image/jpeg", enableRangeProcessing: true); + }) + .RequireAuthorization() + .AddEndpointFilter(async (zusammenhang, weiter) => + { + var ergebnis = await weiter(zusammenhang); + zusammenhang.HttpContext.Response.Headers.CacheControl = "private, max-age=31536000, immutable"; + return ergebnis; + }); + } +} diff --git a/server/src/Halo.Api/Endpoints/LibraryContentEndpoints.cs b/server/src/Halo.Api/Endpoints/LibraryContentEndpoints.cs index babbb10..2d20a8b 100644 --- a/server/src/Halo.Api/Endpoints/LibraryContentEndpoints.cs +++ b/server/src/Halo.Api/Endpoints/LibraryContentEndpoints.cs @@ -38,14 +38,18 @@ public static class LibraryContentEndpoints { var zustand = koordinator.State(id); if (zustand is null) - return Results.Ok(new ScanStatus("idle", null, null, null, null, null, null, null, null, null)); + return Results.Ok(new ScanStatus("idle", 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?.Problems.Select(p => new ScanProblem(p.Path, p.Reason)).ToList(), - zustand.Error)); + zustand.Error, + metadaten is null ? null : new MetadataProgress( + metadaten.MoviesMatched, metadaten.SeriesMatched, metadaten.EpisodesMatched, + metadaten.Skipped, metadaten.Error))); }).WithName("Scanstand").RequireAuthorization(); } @@ -63,7 +67,7 @@ public static class LibraryContentEndpoints var kennungen = await abfrage .OrderBy(m => m.SortTitleValue).ThenBy(m => m.Year) .Skip(Math.Max(0, skip)).Take(Math.Clamp(take, 1, HöchstensJeSeite)) - .Select(m => new { m.Id, m.Title, m.Year, m.Overview }) + .Select(m => new { m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage }) .ToListAsync(); // Dateien in **einer** Abfrage nachholen statt je Film einer. @@ -76,7 +80,7 @@ public static class LibraryContentEndpoints .ToListAsync(); var items = kennungen.Select(m => new MovieResponse( - m.Id, m.Title, m.Year, m.Overview, + m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage, dateien.Where(f => f.MovieId == m.Id) .Select(f => new MediaFileResponse(f.Id, f.Path, f.SizeBytes)).ToList())).ToList(); @@ -95,7 +99,7 @@ public static class LibraryContentEndpoints var serien = await abfrage .OrderBy(s => s.SortTitleValue) .Skip(Math.Max(0, skip)).Take(Math.Clamp(take, 1, HöchstensJeSeite)) - .Select(s => new { s.Id, s.Title, s.Year }) + .Select(s => new { s.Id, s.Title, s.Year, s.Overview, s.PosterImage, s.BackdropImage }) .ToListAsync(); var ids = serien.Select(s => s.Id).ToList(); @@ -108,7 +112,7 @@ public static class LibraryContentEndpoints { var meine = folgen.Where(f => f.SeriesId == s.Id).ToList(); return new SeriesResponse( - s.Id, s.Title, s.Year, meine.Count, + s.Id, s.Title, s.Year, s.Overview, s.PosterImage, s.BackdropImage, meine.Count, meine.Select(f => f.SeasonNumber).Distinct().Order().ToList()); }).ToList(); @@ -124,7 +128,7 @@ public static class LibraryContentEndpoints var folgen = await abfrage .OrderBy(f => f.SeasonNumber).ThenBy(f => f.EpisodeNumber) - .Select(f => new { f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title }) + .Select(f => new { f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview, f.StillImage }) .ToListAsync(); var ids = folgen.Select(f => f.Id).ToList(); @@ -134,7 +138,7 @@ public static class LibraryContentEndpoints .ToListAsync(); var items = folgen.Select(f => new EpisodeResponse( - f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, + f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview, f.StillImage, dateien.Where(d => d.EpisodeId == f.Id) .Select(d => new MediaFileResponse(d.Id, d.Path, d.SizeBytes)).ToList())).ToList(); diff --git a/server/src/Halo.Api/Endpoints/SettingsEndpoints.cs b/server/src/Halo.Api/Endpoints/SettingsEndpoints.cs new file mode 100644 index 0000000..bda99d8 --- /dev/null +++ b/server/src/Halo.Api/Endpoints/SettingsEndpoints.cs @@ -0,0 +1,54 @@ +using Halo.Core; +using Halo.Data; +using Halo.Metadata; +using Microsoft.EntityFrameworkCore; + +namespace Halo.Api.Endpoints; + +public static class SettingsEndpoints +{ + public static void MapSettings(this IEndpointRouteBuilder app) + { + app.MapGet("/api/v1/settings/metadata", async (HaloContext db) => + { + var zeilen = await db.Settings.AsNoTracking() + .Where(s => s.Key == DatabaseTmdbSettings.ApiKeyName || s.Key == DatabaseTmdbSettings.LanguageName) + .ToDictionaryAsync(s => s.Key, s => s.Value); + + // **Der Schlüssel selbst kommt nicht zurück.** Er gäbe jedem + // Angemeldeten das TMDB-Kontingent des Betreibers in die Hand, und + // zum Anzeigen genügt die Auskunft, dass einer da ist. + return Results.Ok(new MetadataSettingsResponse( + HasApiKey: zeilen.GetValueOrDefault(DatabaseTmdbSettings.ApiKeyName) is { Length: > 0 }, + Language: zeilen.GetValueOrDefault(DatabaseTmdbSettings.LanguageName) ?? TmdbSettings.DefaultLanguage)); + }).RequireAuthorization(HaloPolicies.Administrator); + + app.MapPut("/api/v1/settings/metadata", async ( + MetadataSettingsRequest anfrage, HaloContext db) => + { + if (anfrage.ApiKey is not null) await Setzen(db, DatabaseTmdbSettings.ApiKeyName, anfrage.ApiKey.Trim()); + if (anfrage.Language is not null) + { + var sprache = anfrage.Language.Trim(); + // Form wie „de-DE". Alles andere nähme TMDB stillschweigend als + // Englisch an, und niemand wüsste, warum. + if (!System.Text.RegularExpressions.Regex.IsMatch(sprache, "^[a-z]{2}(-[A-Z]{2})?$")) + return Results.ValidationProblem(new Dictionary + { + ["language"] = [$"„{sprache}“ ist keine Sprachkennung. Erwartet wird etwa „de-DE“ oder „en“."], + }); + await Setzen(db, DatabaseTmdbSettings.LanguageName, sprache); + } + + await db.SaveChangesAsync(); + return Results.NoContent(); + }).RequireAuthorization(HaloPolicies.Administrator); + } + + private static async Task Setzen(HaloContext db, string schlüssel, string wert) + { + var vorhanden = await db.Settings.FindAsync(schlüssel); + if (vorhanden is null) db.Settings.Add(new ServerSetting { Key = schlüssel, Value = wert }); + else vorhanden.Value = wert; + } +} diff --git a/server/src/Halo.Api/Halo.Api.csproj b/server/src/Halo.Api/Halo.Api.csproj index 9d046d3..af0984b 100644 --- a/server/src/Halo.Api/Halo.Api.csproj +++ b/server/src/Halo.Api/Halo.Api.csproj @@ -3,6 +3,7 @@ + diff --git a/server/src/Halo.Api/MetadataSettings.cs b/server/src/Halo.Api/MetadataSettings.cs new file mode 100644 index 0000000..b08a8ae --- /dev/null +++ b/server/src/Halo.Api/MetadataSettings.cs @@ -0,0 +1,39 @@ +using Halo.Data; +using Halo.Metadata; +using Microsoft.EntityFrameworkCore; + +namespace Halo.Api; + +/// Holt die Metadaten-Einstellungen aus der Datenbank. +/// +/// +/// Hier und nicht in Halo.Data: sonst müsste die Datenschicht +/// den Metadaten-Anbieter kennen. So zeigen beide Richtungen auf den Kern und +/// keine aufeinander. +/// +/// Je Anfrage einmal gelesen und dann gemerkt. Ohne das fragte ein Scan über +/// tausend Titel tausendmal dieselbe Zeile ab. +/// +public sealed class DatabaseTmdbSettings(HaloContext db) : ITmdbSettingsSource +{ + public const string ApiKeyName = "metadata.tmdb.apiKey"; + public const string LanguageName = "metadata.language"; + + private TmdbSettings? _gemerkt; + + public async Task GetAsync(CancellationToken ct = default) + { + if (_gemerkt is not null) return _gemerkt; + + var zeilen = await db.Settings + .AsNoTracking() + .Where(s => s.Key == ApiKeyName || s.Key == LanguageName) + .ToDictionaryAsync(s => s.Key, s => s.Value, ct); + + return _gemerkt = new TmdbSettings( + zeilen.GetValueOrDefault(ApiKeyName), + zeilen.GetValueOrDefault(LanguageName) is { Length: > 0 } sprache + ? sprache + : TmdbSettings.DefaultLanguage); + } +} diff --git a/server/src/Halo.Api/Program.cs b/server/src/Halo.Api/Program.cs index effb42f..cd338b1 100644 --- a/server/src/Halo.Api/Program.cs +++ b/server/src/Halo.Api/Program.cs @@ -2,7 +2,9 @@ using System.Threading.RateLimiting; using Halo.Api; using Halo.Api.Auth; using Halo.Api.Endpoints; +using Halo.Core.Metadata; using Halo.Data; +using Halo.Metadata; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; @@ -58,6 +60,21 @@ builder.Services.AddRateLimiter(o => }); builder.Services.AddScoped(); + +// Metadaten. Der Anbieter steckt hinter einer Schnittstelle, damit alles, was +// ihn benutzt, ohne Netz und ohne Schlüssel geprüft werden kann. +builder.Services.AddScoped(); +builder.Services.AddHttpClient(c => +{ + c.BaseAddress = new Uri(TmdbClient.BaseAddress); + // Ein hängender fremder Dienst darf einen Scan nicht festsetzen. + c.Timeout = TimeSpan.FromSeconds(20); +}); +builder.Services.AddScoped(sp => sp.GetRequiredService()); +builder.Services.AddSingleton(new ImageCacheOptions(pfade.Cache)); +builder.Services.AddHttpClient(c => c.Timeout = TimeSpan.FromSeconds(30)); +builder.Services.AddScoped(sp => sp.GetRequiredService()); +builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddProblemDetails(); @@ -80,6 +97,8 @@ app.MapSystem(); app.MapAuth(); app.MapManagement(); app.MapLibraryContent(); +app.MapSettings(); +app.MapImages(); app.Run(); diff --git a/server/src/Halo.Api/ScanCoordinator.cs b/server/src/Halo.Api/ScanCoordinator.cs index e0174cd..a03bef7 100644 --- a/server/src/Halo.Api/ScanCoordinator.cs +++ b/server/src/Halo.Api/ScanCoordinator.cs @@ -8,6 +8,7 @@ public sealed record ScanState( DateTimeOffset StartedAt, DateTimeOffset? FinishedAt, ScanReport? Report, + EnrichReport? Enrichment, string? Error); /// Lässt Scans im Hintergrund laufen und weiss, wie weit sie sind. @@ -44,7 +45,7 @@ public sealed class ScanCoordinator( /// Startet einen Lauf — oder sagt, dass schon einer läuft. public bool TryStart(Guid libraryId) { - var start = new ScanState("running", clock.GetUtcNow(), null, null, null); + var start = new ScanState("running", clock.GetUtcNow(), null, null, null, null); var vorher = _zustand.AddOrUpdate( libraryId, @@ -66,11 +67,19 @@ public sealed class ScanCoordinator( var scanner = bereich.ServiceProvider.GetRequiredService(); var bericht = await scanner.ScanAsync(libraryId); + // Der Scan ist durch, die Dateien stehen in der Mediathek. Was + // jetzt noch kommt, kann scheitern, ohne dass davon etwas verloren + // geht — deshalb erst hier und in eigenem Zustand. + _zustand[libraryId] = _zustand[libraryId] with { Status = "enriching", Report = bericht }; + + var metadaten = await bereich.ServiceProvider + .GetRequiredService().EnrichAsync(libraryId); + _zustand[libraryId] = _zustand[libraryId] with { Status = "finished", FinishedAt = clock.GetUtcNow(), - Report = bericht, + Enrichment = metadaten, }; } catch (Exception fehler) diff --git a/server/src/Halo.Core/Library/MediaEntities.cs b/server/src/Halo.Core/Library/MediaEntities.cs index dbdfe48..0d9b315 100644 --- a/server/src/Halo.Core/Library/MediaEntities.cs +++ b/server/src/Halo.Core/Library/MediaEntities.cs @@ -41,6 +41,17 @@ public sealed class Movie { public Guid Id { get; init; } = Guid.CreateVersion7(); public required Guid LibraryId { get; init; } + /// Der Titel, wie er aus dem Dateinamen gelesen wurde. + /// + /// + /// Daran ordnet der Scan zu, nicht an . Der + /// angezeigte Titel kommt nach dem ersten Lauf von der Metadatenquelle und + /// ändert sich dabei — aus „The Matrix" wird „Matrix". Würde der nächste + /// Scan darauf suchen, fände er nichts, legte den Film ein zweites Mal an, + /// und die Mediathek wüchse bei jedem Lauf. Dieses Feld ändert sich nie. + /// + public required string ParsedTitle { get; init; } + public required string Title { get; set; } /// Ohne führenden Artikel — siehe . @@ -52,6 +63,17 @@ public sealed class Movie /// Die Kennung bei TMDB, sobald zugeordnet. public int? TmdbId { get; set; } + /// Schlüssel des zwischengespeicherten Plakats — siehe Bildablage. + /// + /// + /// Der Schlüssel, nicht die Adresse bei TMDB. Würde die App dort unmittelbar + /// laden, bräuchte sie Internet für ein Plakat, das zehn Zentimeter weiter + /// auf der Platte liegt — und TMDB erführe, wer wann was ansieht. + /// + public string? PosterImage { get; set; } + + public string? BackdropImage { get; set; } + public DateTimeOffset AddedAt { get; init; } = DateTimeOffset.UtcNow; } @@ -59,11 +81,33 @@ public sealed class Series { public Guid Id { get; init; } = Guid.CreateVersion7(); public required Guid LibraryId { get; init; } + /// Der Titel, wie er aus dem Dateinamen gelesen wurde. + /// + /// + /// Daran ordnet der Scan zu, nicht an . Der + /// angezeigte Titel kommt nach dem ersten Lauf von der Metadatenquelle und + /// ändert sich dabei — aus „The Matrix" wird „Matrix". Würde der nächste + /// Scan darauf suchen, fände er nichts, legte den Film ein zweites Mal an, + /// und die Mediathek wüchse bei jedem Lauf. Dieses Feld ändert sich nie. + /// + public required string ParsedTitle { get; init; } + public required string Title { get; set; } public required string SortTitleValue { get; set; } public int? Year { get; set; } public string? Overview { get; set; } public int? TmdbId { get; set; } + /// Schlüssel des zwischengespeicherten Plakats — siehe Bildablage. + /// + /// + /// Der Schlüssel, nicht die Adresse bei TMDB. Würde die App dort unmittelbar + /// laden, bräuchte sie Internet für ein Plakat, das zehn Zentimeter weiter + /// auf der Platte liegt — und TMDB erführe, wer wann was ansieht. + /// + public string? PosterImage { get; set; } + + public string? BackdropImage { get; set; } + public DateTimeOffset AddedAt { get; init; } = DateTimeOffset.UtcNow; } @@ -75,5 +119,9 @@ public sealed class Episode public required int EpisodeNumber { get; init; } public string? Title { get; set; } public string? Overview { get; set; } + + /// Das Standbild der Folge. + public string? StillImage { get; set; } + public DateTimeOffset AddedAt { get; init; } = DateTimeOffset.UtcNow; } diff --git a/server/src/Halo.Core/Metadata/IMetadataProvider.cs b/server/src/Halo.Core/Metadata/IMetadataProvider.cs new file mode 100644 index 0000000..a5a6ae0 --- /dev/null +++ b/server/src/Halo.Core/Metadata/IMetadataProvider.cs @@ -0,0 +1,69 @@ +namespace Halo.Core.Metadata; + +/// Ein Bild bei der Quelle — noch nicht heruntergeladen. +public sealed record RemoteImage(string Url); + +public sealed record MovieMetadata( + int ProviderId, string Title, int? Year, string? Overview, + RemoteImage? Poster, RemoteImage? Backdrop); + +public sealed record SeriesMetadata( + int ProviderId, string Title, int? Year, string? Overview, + RemoteImage? Poster, RemoteImage? Backdrop); + +public sealed record EpisodeMetadata(string? Title, string? Overview, RemoteImage? Still); + +/// Woher Titel, Beschreibungen und Bilder kommen. +/// +/// +/// +/// Als Schnittstelle, obwohl es vorerst nur TMDB gibt. Nicht aus +/// Vorsorge für einen zweiten Anbieter, sondern damit sich alles, was darauf +/// aufbaut, ohne Netz und ohne Schlüssel prüfen lässt. Ein Projekt, +/// dessen Tests einen fremden Dienst brauchen, hat am Ende keine Tests. +/// +/// +/// null heisst durchweg: nichts gefunden. Das ist kein Fehler — ein +/// Bestand enthält Mitschnitte und Heimvideos, die bei keiner Quelle stehen, +/// und die sollen trotzdem in der Mediathek erscheinen. +/// +/// +public interface IMetadataProvider +{ + /// Ob ein Schlüssel hinterlegt ist. Ohne ihn wird nichts gefragt. + /// + /// + /// Asynchron, weil der Schlüssel in der Datenbank steht und nicht im Code + /// — der Betreiber trägt ihn in der Oberfläche ein, nicht in einer Datei. + /// + Task IsConfiguredAsync(CancellationToken ct = default); + + Task FindMovieAsync(string title, int? year, CancellationToken ct = default); + + Task FindSeriesAsync(string title, int? year, CancellationToken ct = default); + + Task FindEpisodeAsync(int seriesProviderId, int season, int episode, CancellationToken ct = default); +} + +/// Der Schlüssel stimmt nicht — das muss der Betreiber erfahren. +/// +/// +/// Ausdrücklich eine eigene Ausnahme und kein stilles null: „nichts +/// gefunden" und „dein Schlüssel ist falsch" sehen in der Mediathek gleich +/// aus, sind aber völlig verschiedene Lagen. Ohne diese Unterscheidung sucht +/// jemand stundenlang nach dem Grund, warum keine Bilder erscheinen. +/// +public sealed class MetadataCredentialsException(string nachricht) : Exception(nachricht); + +/// Legt Bilder lokal ab, damit Clients sie nicht beim Anbieter holen. +/// +/// +/// Liegt im Kern, obwohl die Umsetzung herunterlädt: so hängt der Teil, der +/// Metadaten einträgt, an einem Vertrag und nicht an einem HTTP-Client — +/// und lässt sich ohne Netz prüfen. +/// +public interface IImageStore +{ + /// Holt das Bild und gibt seinen Schlüssel zurück — oder null. + Task StoreAsync(RemoteImage bild, CancellationToken ct = default); +} diff --git a/server/src/Halo.Data/LibraryScanner.cs b/server/src/Halo.Data/LibraryScanner.cs index 5d53c25..045b703 100644 --- a/server/src/Halo.Data/LibraryScanner.cs +++ b/server/src/Halo.Data/LibraryScanner.cs @@ -156,19 +156,22 @@ public sealed class LibraryScanner(HaloContext db, TimeProvider clock, ILoggerSucht den Film — oder legt ihn an. /// /// - /// Titel **und** Jahr entscheiden. Nur über den Titel zu gehen würfe die - /// beiden „Dune" von 1984 und 2021 zusammen; nur über die Datei zu gehen - /// machte aus 4K- und 1080p-Fassung zwei Filme. + /// Der Titel **aus dem Dateinamen** und das Jahr entscheiden — nicht der + /// angezeigte Titel, der nach dem ersten Lauf von der Metadatenquelle + /// kommt und sich dabei ändert. Nur über den Titel ohne Jahr zu gehen + /// würfe die beiden „Dune" von 1984 und 2021 zusammen; über die Datei zu + /// gehen machte aus 4K- und 1080p-Fassung zwei Filme. /// private Movie FilmFinden(List bekannt, Guid libraryId, MovieName name) { var treffer = bekannt.FirstOrDefault(m => - string.Equals(m.Title, name.Title, StringComparison.OrdinalIgnoreCase) && m.Year == name.Year); + string.Equals(m.ParsedTitle, name.Title, StringComparison.OrdinalIgnoreCase) && m.Year == name.Year); if (treffer is not null) return treffer; var neu = new Movie { LibraryId = libraryId, + ParsedTitle = name.Title, Title = name.Title, SortTitleValue = SortTitle.For(name.Title), Year = name.Year, @@ -180,12 +183,14 @@ public sealed class LibraryScanner(HaloContext db, TimeProvider clock, ILogger bekannt, Guid libraryId, string titel) { - var treffer = bekannt.FirstOrDefault(s => string.Equals(s.Title, titel, StringComparison.OrdinalIgnoreCase)); + var treffer = bekannt.FirstOrDefault(s => + string.Equals(s.ParsedTitle, titel, StringComparison.OrdinalIgnoreCase)); if (treffer is not null) return treffer; var neu = new Series { LibraryId = libraryId, + ParsedTitle = titel, Title = titel, SortTitleValue = SortTitle.For(titel), }; diff --git a/server/src/Halo.Data/MetadataEnricher.cs b/server/src/Halo.Data/MetadataEnricher.cs new file mode 100644 index 0000000..41cb857 --- /dev/null +++ b/server/src/Halo.Data/MetadataEnricher.cs @@ -0,0 +1,147 @@ +using Halo.Core.Metadata; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Halo.Data; + +public sealed record EnrichReport( + int MoviesMatched, int SeriesMatched, int EpisodesMatched, bool Skipped, string? Error); + +/// Trägt nach dem Scan Titel, Beschreibungen und Bilder ein. +/// +/// +/// +/// Getrennt vom Scan, und das mit Absicht. Der Scan liest die Platte +/// und ist in Sekunden fertig; das Nachschlagen hängt an einem fremden Dienst +/// und kann Minuten dauern oder gar nicht gelingen. Wären sie eins, stünde die +/// Mediathek leer, bloss weil TMDB gerade nicht antwortet — obwohl die Dateien +/// tadellos vorhanden sind. +/// +/// +/// Nur, was noch keine Zuordnung hat. Ein zweiter Lauf fragt nichts +/// erneut. Das ist nicht nur schneller, es hält auch Handarbeit: ein von Hand +/// berichtigter Titel bleibt stehen. +/// +/// +public sealed class MetadataEnricher( + HaloContext db, + IMetadataProvider anbieter, + IImageStore bilder, + ILogger log) +{ + public async Task EnrichAsync(Guid libraryId, CancellationToken ct = default) + { + if (!await anbieter.IsConfiguredAsync(ct)) + { + log.LogInformation("Kein Metadaten-Schlüssel hinterlegt — die Titel bleiben bei den Dateinamen."); + return new EnrichReport(0, 0, 0, Skipped: true, null); + } + + int filme = 0, serien = 0, folgen = 0; + try + { + filme = await FilmeZuordnen(libraryId, ct); + serien = await SerienZuordnen(libraryId, ct); + folgen = await FolgenZuordnen(libraryId, ct); + } + catch (MetadataCredentialsException fehler) + { + // Die eine Lage, die jemand erfahren muss: „nichts gefunden" und + // „dein Schlüssel ist falsch" sehen in der Mediathek gleich aus. + log.LogError("{Meldung}", fehler.Message); + return new EnrichReport(filme, serien, folgen, Skipped: false, fehler.Message); + } + + log.LogInformation( + "Metadaten eingetragen: {Filme} Filme, {Serien} Serien, {Folgen} Folgen.", filme, serien, folgen); + return new EnrichReport(filme, serien, folgen, Skipped: false, null); + } + + private async Task FilmeZuordnen(Guid libraryId, CancellationToken ct) + { + var offen = await db.Movies.Where(m => m.LibraryId == libraryId && m.TmdbId == null).ToListAsync(ct); + var zugeordnet = 0; + + foreach (var film in offen) + { + ct.ThrowIfCancellationRequested(); + var gefunden = await anbieter.FindMovieAsync(film.Title, film.Year, ct); + if (gefunden is null) continue; + + film.TmdbId = gefunden.ProviderId; + film.Title = gefunden.Title; + film.SortTitleValue = Core.Library.SortTitle.For(gefunden.Title); + film.Year ??= gefunden.Year; + film.Overview = gefunden.Overview; + film.PosterImage = await BildAblegen(gefunden.Poster, ct); + film.BackdropImage = await BildAblegen(gefunden.Backdrop, ct); + zugeordnet++; + } + + if (zugeordnet > 0) await db.SaveChangesAsync(ct); + return zugeordnet; + } + + private async Task SerienZuordnen(Guid libraryId, CancellationToken ct) + { + var offen = await db.Series.Where(s => s.LibraryId == libraryId && s.TmdbId == null).ToListAsync(ct); + var zugeordnet = 0; + + foreach (var serie in offen) + { + ct.ThrowIfCancellationRequested(); + var gefunden = await anbieter.FindSeriesAsync(serie.Title, serie.Year, ct); + if (gefunden is null) continue; + + serie.TmdbId = gefunden.ProviderId; + serie.Title = gefunden.Title; + serie.SortTitleValue = Core.Library.SortTitle.For(gefunden.Title); + serie.Year ??= gefunden.Year; + serie.Overview = gefunden.Overview; + serie.PosterImage = await BildAblegen(gefunden.Poster, ct); + serie.BackdropImage = await BildAblegen(gefunden.Backdrop, ct); + zugeordnet++; + } + + if (zugeordnet > 0) await db.SaveChangesAsync(ct); + return zugeordnet; + } + + /// Folgen nachschlagen — aber nur bei Serien, die zugeordnet sind. + private async Task FolgenZuordnen(Guid libraryId, CancellationToken ct) + { + var serien = await db.Series + .Where(s => s.LibraryId == libraryId && s.TmdbId != null) + .Select(s => new { s.Id, TmdbId = s.TmdbId!.Value }) + .ToListAsync(ct); + if (serien.Count == 0) return 0; + + var kennungen = serien.Select(s => s.Id).ToList(); + var offen = await db.Episodes + .Where(f => kennungen.Contains(f.SeriesId) && f.Overview == null) + .ToListAsync(ct); + + var zugeordnet = 0; + foreach (var folge in offen) + { + ct.ThrowIfCancellationRequested(); + var serie = serien.First(s => s.Id == folge.SeriesId); + + var gefunden = await anbieter.FindEpisodeAsync(serie.TmdbId, folge.SeasonNumber, folge.EpisodeNumber, ct); + if (gefunden is null) continue; + + // Ein Titel aus dem Dateinamen wird ersetzt: TMDB kennt ihn besser. + // Ein leerer Fund überschreibt dagegen nichts. + folge.Title = gefunden.Title ?? folge.Title; + folge.Overview = gefunden.Overview; + folge.StillImage = await BildAblegen(gefunden.Still, ct); + zugeordnet++; + } + + if (zugeordnet > 0) await db.SaveChangesAsync(ct); + return zugeordnet; + } + + private async Task BildAblegen(RemoteImage? bild, CancellationToken ct) => + bild is null ? null : await bilder.StoreAsync(bild, ct); +} diff --git a/server/src/Halo.Data/Migrations/20261003120100_Metadaten.Designer.cs b/server/src/Halo.Data/Migrations/20261003120100_Metadaten.Designer.cs new file mode 100644 index 0000000..f64f72f --- /dev/null +++ b/server/src/Halo.Data/Migrations/20261003120100_Metadaten.Designer.cs @@ -0,0 +1,402 @@ +// +using System; +using Halo.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Halo.Data.Migrations +{ + [DbContext(typeof(HaloContext))] + [Migration("20261003120100_Metadaten")] + partial class Metadaten + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.12"); + + modelBuilder.Entity("Halo.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("Halo.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("Halo.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("Halo.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("Halo.Core.Library.MediaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + 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("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("Halo.Core.Library.Movie", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedAt") + .HasColumnType("INTEGER"); + + b.Property("BackdropImage") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("PosterImage") + .HasColumnType("TEXT"); + + b.Property("SortTitleValue") + .IsRequired() + .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("Halo.Core.Library.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedAt") + .HasColumnType("INTEGER"); + + b.Property("BackdropImage") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("PosterImage") + .HasColumnType("TEXT"); + + b.Property("SortTitleValue") + .IsRequired() + .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("Halo.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("Halo.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("Halo.Core.Auth.DeviceToken", b => + { + b.HasOne("Halo.Core.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Halo.Core.Library.Episode", b => + { + b.HasOne("Halo.Core.Library.Series", null) + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Halo.Core.Library.MediaFile", b => + { + b.HasOne("Halo.Core.Libraries.Library", null) + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Halo.Core.Library.Movie", b => + { + b.HasOne("Halo.Core.Libraries.Library", null) + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Halo.Core.Library.Series", b => + { + b.HasOne("Halo.Core.Libraries.Library", null) + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/server/src/Halo.Data/Migrations/20261003120100_Metadaten.cs b/server/src/Halo.Data/Migrations/20261003120100_Metadaten.cs new file mode 100644 index 0000000..8bb9234 --- /dev/null +++ b/server/src/Halo.Data/Migrations/20261003120100_Metadaten.cs @@ -0,0 +1,68 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Halo.Data.Migrations +{ + /// + public partial class Metadaten : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "BackdropImage", + table: "Series", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "PosterImage", + table: "Series", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "BackdropImage", + table: "Movies", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "PosterImage", + table: "Movies", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "StillImage", + table: "Episodes", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "BackdropImage", + table: "Series"); + + migrationBuilder.DropColumn( + name: "PosterImage", + table: "Series"); + + migrationBuilder.DropColumn( + name: "BackdropImage", + table: "Movies"); + + migrationBuilder.DropColumn( + name: "PosterImage", + table: "Movies"); + + migrationBuilder.DropColumn( + name: "StillImage", + table: "Episodes"); + } + } +} diff --git a/server/src/Halo.Data/Migrations/20261003123234_GeparsterTitel.Designer.cs b/server/src/Halo.Data/Migrations/20261003123234_GeparsterTitel.Designer.cs new file mode 100644 index 0000000..88d9f04 --- /dev/null +++ b/server/src/Halo.Data/Migrations/20261003123234_GeparsterTitel.Designer.cs @@ -0,0 +1,410 @@ +// +using System; +using Halo.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Halo.Data.Migrations +{ + [DbContext(typeof(HaloContext))] + [Migration("20261003123234_GeparsterTitel")] + partial class GeparsterTitel + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.12"); + + modelBuilder.Entity("Halo.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("Halo.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("Halo.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("Halo.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("Halo.Core.Library.MediaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + 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("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("Halo.Core.Library.Movie", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedAt") + .HasColumnType("INTEGER"); + + b.Property("BackdropImage") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("ParsedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PosterImage") + .HasColumnType("TEXT"); + + b.Property("SortTitleValue") + .IsRequired() + .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("Halo.Core.Library.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedAt") + .HasColumnType("INTEGER"); + + b.Property("BackdropImage") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("ParsedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PosterImage") + .HasColumnType("TEXT"); + + b.Property("SortTitleValue") + .IsRequired() + .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("Halo.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("Halo.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("Halo.Core.Auth.DeviceToken", b => + { + b.HasOne("Halo.Core.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Halo.Core.Library.Episode", b => + { + b.HasOne("Halo.Core.Library.Series", null) + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Halo.Core.Library.MediaFile", b => + { + b.HasOne("Halo.Core.Libraries.Library", null) + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Halo.Core.Library.Movie", b => + { + b.HasOne("Halo.Core.Libraries.Library", null) + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Halo.Core.Library.Series", b => + { + b.HasOne("Halo.Core.Libraries.Library", null) + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/server/src/Halo.Data/Migrations/20261003123234_GeparsterTitel.cs b/server/src/Halo.Data/Migrations/20261003123234_GeparsterTitel.cs new file mode 100644 index 0000000..f1753fd --- /dev/null +++ b/server/src/Halo.Data/Migrations/20261003123234_GeparsterTitel.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Halo.Data.Migrations +{ + /// + public partial class GeparsterTitel : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ParsedTitle", + table: "Series", + type: "TEXT", + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "ParsedTitle", + table: "Movies", + type: "TEXT", + nullable: false, + defaultValue: ""); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ParsedTitle", + table: "Series"); + + migrationBuilder.DropColumn( + name: "ParsedTitle", + table: "Movies"); + } + } +} diff --git a/server/src/Halo.Data/Migrations/HaloContextModelSnapshot.cs b/server/src/Halo.Data/Migrations/HaloContextModelSnapshot.cs index 071a28e..1d4372b 100644 --- a/server/src/Halo.Data/Migrations/HaloContextModelSnapshot.cs +++ b/server/src/Halo.Data/Migrations/HaloContextModelSnapshot.cs @@ -158,6 +158,9 @@ namespace Halo.Data.Migrations b.Property("SeriesId") .HasColumnType("TEXT"); + b.Property("StillImage") + .HasColumnType("TEXT"); + b.Property("Title") .HasMaxLength(512) .HasColumnType("TEXT"); @@ -220,12 +223,22 @@ namespace Halo.Data.Migrations b.Property("AddedAt") .HasColumnType("INTEGER"); + b.Property("BackdropImage") + .HasColumnType("TEXT"); + b.Property("LibraryId") .HasColumnType("TEXT"); b.Property("Overview") .HasColumnType("TEXT"); + b.Property("ParsedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PosterImage") + .HasColumnType("TEXT"); + b.Property("SortTitleValue") .IsRequired() .HasMaxLength(512) @@ -258,12 +271,22 @@ namespace Halo.Data.Migrations b.Property("AddedAt") .HasColumnType("INTEGER"); + b.Property("BackdropImage") + .HasColumnType("TEXT"); + b.Property("LibraryId") .HasColumnType("TEXT"); b.Property("Overview") .HasColumnType("TEXT"); + b.Property("ParsedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PosterImage") + .HasColumnType("TEXT"); + b.Property("SortTitleValue") .IsRequired() .HasMaxLength(512) diff --git a/server/src/Halo.Metadata/Halo.Metadata.csproj b/server/src/Halo.Metadata/Halo.Metadata.csproj new file mode 100644 index 0000000..7b42664 --- /dev/null +++ b/server/src/Halo.Metadata/Halo.Metadata.csproj @@ -0,0 +1,17 @@ + + + + + + + + + + + + net10.0 + enable + enable + + + diff --git a/server/src/Halo.Metadata/ImageCache.cs b/server/src/Halo.Metadata/ImageCache.cs new file mode 100644 index 0000000..e0e67db --- /dev/null +++ b/server/src/Halo.Metadata/ImageCache.cs @@ -0,0 +1,96 @@ +using System.Security.Cryptography; +using System.Text; +using Halo.Core.Metadata; +using Microsoft.Extensions.Logging; + +namespace Halo.Metadata; + +public sealed record ImageCacheOptions(string Root); + +/// Lädt Bilder einmal herunter und behält sie. +/// +/// +/// +/// Der Schlüssel ist der Hash der Quelladresse. Damit lädt dasselbe +/// Plakat nie zweimal — und zwei Serien, die sich ein Bild teilen, belegen es +/// einmal. Nebenbei enthält der Schlüssel nichts, woraus sich etwas ableiten +/// liesse. +/// +/// +/// Abgelegt wird in Unterordnern nach den ersten zwei Zeichen. Ein +/// Bestand mit zehntausend Titeln ergibt zwanzigtausend Bilder, und ein +/// Verzeichnis mit zwanzigtausend Einträgen bringt manche Dateisysteme — und +/// jedes `ls` darauf — spürbar ins Stocken. +/// +/// +public sealed class ImageCache( + HttpClient http, + ImageCacheOptions optionen, + ILogger log) : IImageStore +{ + /// Mehr als das ist kein Plakat, sondern ein Versehen. + private const long HöchstensBytes = 16 * 1024 * 1024; + + public async Task StoreAsync(RemoteImage bild, CancellationToken ct = default) + { + var schlüssel = Schlüssel(bild.Url); + var ziel = PfadFür(schlüssel); + if (ziel is null) return null; + + if (File.Exists(ziel)) return schlüssel; + + try + { + using var antwort = await http.GetAsync(bild.Url, HttpCompletionOption.ResponseHeadersRead, ct); + if (!antwort.IsSuccessStatusCode) + { + log.LogWarning("Bild {Adresse} kam mit {Code} zurück.", bild.Url, (int)antwort.StatusCode); + return null; + } + + if (antwort.Content.Headers.ContentLength > HöchstensBytes) + { + log.LogWarning("Bild {Adresse} ist grösser als erlaubt und wird übergangen.", bild.Url); + return null; + } + + Directory.CreateDirectory(Path.GetDirectoryName(ziel)!); + + // Erst daneben schreiben, dann umbenennen. Bricht der Download ab, + // liegt sonst eine halbe Datei unter dem richtigen Namen — und die + // wird nie wieder nachgeladen, weil der Schlüssel ja vorhanden ist. + var halbfertig = ziel + ".teil"; + await using (var datei = File.Create(halbfertig)) + { + await antwort.Content.CopyToAsync(datei, ct); + } + File.Move(halbfertig, ziel, overwrite: true); + + return schlüssel; + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } + catch (Exception fehler) + { + log.LogWarning(fehler, "Bild {Adresse} liess sich nicht ablegen.", bild.Url); + return null; + } + } + + /// Wo ein Schlüssel liegt — oder null, wenn er keiner ist. + /// + /// + /// Die Prüfung ist kein Schmuck. Der Schlüssel kommt aus einer + /// Adresszeile. Ohne sie wäre /api/v1/images/..%2f..%2fconfig%2fhalo.db + /// ein Weg, die Datenbank herunterzuladen — samt aller Token-Fingerabdrücke. + /// + public string? PfadFür(string schlüssel) + { + if (schlüssel.Length != 64 || !schlüssel.All(c => c is >= '0' and <= '9' or >= 'a' and <= 'f')) + return null; + + return Path.Combine(optionen.Root, "images", schlüssel[..2], schlüssel + ".img"); + } + + private static string Schlüssel(string adresse) => + Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(adresse))); +} diff --git a/server/src/Halo.Metadata/TmdbClient.cs b/server/src/Halo.Metadata/TmdbClient.cs new file mode 100644 index 0000000..4d6ba1a --- /dev/null +++ b/server/src/Halo.Metadata/TmdbClient.cs @@ -0,0 +1,203 @@ +using System.Net; +using System.Text.Json; +using System.Text.Json.Serialization; +using Halo.Core.Metadata; +using Microsoft.Extensions.Logging; + +namespace Halo.Metadata; + +/// Fragt TMDB nach Titeln, Beschreibungen und Bildern. +/// +/// +/// +/// Ein Fehlschlag ist kein Abbruch. Antwortet TMDB nicht — kein Netz, +/// Kontingent erschöpft, Wartung —, kommt null zurück und der Titel +/// bleibt beim Dateinamen. Ein Scan, der an einem fremden Dienst scheitert, +/// wäre der falsche Entwurf: die Mediathek steht dann leer da, obwohl die +/// Dateien tadellos vorhanden sind. +/// +/// +/// Ein falscher Schlüssel ist dagegen sehr wohl etwas, das jemand erfahren +/// muss — sonst sucht man stundenlang, warum keine Bilder erscheinen. +/// Deshalb nur dafür eine Ausnahme. +/// +/// +public sealed class TmdbClient( + HttpClient http, + ITmdbSettingsSource einstellungen, + ILogger log) : IMetadataProvider +{ + public const string BaseAddress = "https://api.themoviedb.org/3/"; + + /// w500 für Plakate, w1280 für Hintergründe — genug für jeden Bildschirm. + /// + /// + /// Die Originalgrösse wäre bei einem Bestand mit tausend Titeln mehrere + /// Gigabyte Cache, ohne dass es auf einem Telefon je sichtbar würde. + /// + private const string PosterSize = "w500"; + private const string BackdropSize = "w1280"; + private const string StillSize = "w300"; + + private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); + + public async Task IsConfiguredAsync(CancellationToken ct = default) => + !string.IsNullOrWhiteSpace((await einstellungen.GetAsync(ct)).ApiKey); + + public async Task FindMovieAsync(string title, int? year, CancellationToken ct = default) + { + var treffer = await SuchenAsync("search/movie", title, + year is { } j ? ("year", j.ToString()) : null, ct); + if (treffer is null) return null; + + return new MovieMetadata( + treffer.Id, + treffer.Title ?? title, + JahrAus(treffer.ReleaseDate), + Leer(treffer.Overview), + Bild(treffer.PosterPath, PosterSize), + Bild(treffer.BackdropPath, BackdropSize)); + } + + public async Task FindSeriesAsync(string title, int? year, CancellationToken ct = default) + { + var treffer = await SuchenAsync("search/tv", title, + year is { } j ? ("first_air_date_year", j.ToString()) : null, ct); + if (treffer is null) return null; + + return new SeriesMetadata( + treffer.Id, + treffer.Name ?? title, + JahrAus(treffer.FirstAirDate), + Leer(treffer.Overview), + Bild(treffer.PosterPath, PosterSize), + Bild(treffer.BackdropPath, BackdropSize)); + } + + public async Task FindEpisodeAsync( + int seriesProviderId, int season, int episode, CancellationToken ct = default) + { + var einstellung = await einstellungen.GetAsync(ct); + if (string.IsNullOrWhiteSpace(einstellung.ApiKey)) return null; + + var pfad = $"tv/{seriesProviderId}/season/{season}/episode/{episode}" + + $"?api_key={Uri.EscapeDataString(einstellung.ApiKey)}" + + $"&language={Uri.EscapeDataString(einstellung.Language)}"; + + var folge = await HolenAsync(pfad, ct); + if (folge is null) return null; + + return new EpisodeMetadata(Leer(folge.Name), Leer(folge.Overview), Bild(folge.StillPath, StillSize)); + } + + private async Task SuchenAsync( + string pfad, string titel, (string Name, string Wert)? zusatz, CancellationToken ct) where T : class + { + var einstellung = await einstellungen.GetAsync(ct); + if (string.IsNullOrWhiteSpace(einstellung.ApiKey) || string.IsNullOrWhiteSpace(titel)) return null; + + var abfrage = $"{pfad}?api_key={Uri.EscapeDataString(einstellung.ApiKey)}" + + $"&language={Uri.EscapeDataString(einstellung.Language)}" + + $"&query={Uri.EscapeDataString(titel)}"; + if (zusatz is { } z) abfrage += $"&{z.Name}={Uri.EscapeDataString(z.Wert)}"; + + var antwort = await HolenAsync>(abfrage, ct); + + // **Der erste Treffer, nicht der beste.** TMDB sortiert nach + // Beliebtheit, und das ist für einen Titel mit Jahr fast immer richtig. + // Eine eigene Bewertung zu bauen hiesse, TMDBs Rangfolge nachzubauen — + // mit weniger Daten. + return antwort?.Results?.FirstOrDefault(); + } + + private async Task HolenAsync(string pfadMitAbfrage, CancellationToken ct) where T : class + { + try + { + using var antwort = await http.GetAsync(pfadMitAbfrage, ct); + + if (antwort.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) + throw new MetadataCredentialsException( + "TMDB weist den hinterlegten Schlüssel zurück. Prüfe ihn in den Servereinstellungen."); + + // 404 heisst bei TMDB schlicht „gibt es nicht" — etwa eine Folge, + // die dort noch nicht eingetragen ist. + if (antwort.StatusCode == HttpStatusCode.NotFound) return null; + + if (!antwort.IsSuccessStatusCode) + { + log.LogWarning("TMDB antwortete mit {Code} auf {Pfad}.", (int)antwort.StatusCode, Gekürzt(pfadMitAbfrage)); + return null; + } + + return await antwort.Content.ReadFromJsonSafeAsync(Json, ct); + } + catch (MetadataCredentialsException) { throw; } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } + catch (Exception fehler) + { + log.LogWarning(fehler, "TMDB war für {Pfad} nicht erreichbar.", Gekürzt(pfadMitAbfrage)); + return null; + } + } + + /// Der Pfad ohne den Schlüssel — der gehört in kein Protokoll. + private static string Gekürzt(string pfadMitAbfrage) + { + var index = pfadMitAbfrage.IndexOf("api_key=", StringComparison.Ordinal); + return index < 0 ? pfadMitAbfrage : pfadMitAbfrage[..index] + "api_key=…"; + } + + private static RemoteImage? Bild(string? pfad, string grösse) => + string.IsNullOrWhiteSpace(pfad) ? null : new RemoteImage($"https://image.tmdb.org/t/p/{grösse}{pfad}"); + + private static string? Leer(string? wert) => string.IsNullOrWhiteSpace(wert) ? null : wert; + + private static int? JahrAus(string? datum) => + datum is { Length: >= 4 } && int.TryParse(datum[..4], out var jahr) ? jahr : null; + + // Nur die Felder, die gebraucht werden. TMDB liefert dreissig weitere. + private sealed record TmdbSearch([property: JsonPropertyName("results")] List? Results); + + private sealed record TmdbMovie( + [property: JsonPropertyName("id")] int Id, + [property: JsonPropertyName("title")] string? Title, + [property: JsonPropertyName("release_date")] string? ReleaseDate, + [property: JsonPropertyName("overview")] string? Overview, + [property: JsonPropertyName("poster_path")] string? PosterPath, + [property: JsonPropertyName("backdrop_path")] string? BackdropPath); + + private sealed record TmdbSeries( + [property: JsonPropertyName("id")] int Id, + [property: JsonPropertyName("name")] string? Name, + [property: JsonPropertyName("first_air_date")] string? FirstAirDate, + [property: JsonPropertyName("overview")] string? Overview, + [property: JsonPropertyName("poster_path")] string? PosterPath, + [property: JsonPropertyName("backdrop_path")] string? BackdropPath); + + private sealed record TmdbEpisode( + [property: JsonPropertyName("name")] string? Name, + [property: JsonPropertyName("overview")] string? Overview, + [property: JsonPropertyName("still_path")] string? StillPath); +} + +internal static class HttpContentExtensions +{ + /// Liest JSON und wirft nicht, wenn Unsinn zurückkommt. + /// + /// + /// Ein Reverse Proxy oder ein Anmeldeportal im Hotel-WLAN antwortet mit + /// HTML und dem Status 200. Darauf mit einer Ausnahme zu reagieren hiesse, + /// den ganzen Scan an einem fremden Netz scheitern zu lassen. + /// + public static async Task ReadFromJsonSafeAsync( + this HttpContent inhalt, JsonSerializerOptions optionen, CancellationToken ct) where T : class + { + try + { + await using var strom = await inhalt.ReadAsStreamAsync(ct); + return await JsonSerializer.DeserializeAsync(strom, optionen, ct); + } + catch (JsonException) { return null; } + } +} diff --git a/server/src/Halo.Metadata/TmdbSettings.cs b/server/src/Halo.Metadata/TmdbSettings.cs new file mode 100644 index 0000000..4923243 --- /dev/null +++ b/server/src/Halo.Metadata/TmdbSettings.cs @@ -0,0 +1,27 @@ +namespace Halo.Metadata; + +/// Was TMDB braucht, um zu antworten. +public sealed record TmdbSettings(string? ApiKey, string Language) +{ + /// Englisch als Voreinstellung, weil Halo nicht nur hier läuft. + /// + /// + /// Der Betreiber stellt in der Oberfläche um. Eine deutsche Voreinstellung + /// wäre bequem für genau einen Bestand und für jeden anderen eine + /// Überraschung. + /// + public const string DefaultLanguage = "en-US"; + + public static readonly TmdbSettings Unconfigured = new(null, DefaultLanguage); +} + +/// Woher die Einstellungen kommen. +/// +/// +/// Eine Schnittstelle, damit der Client gegen feste Werte geprüft werden kann, +/// ohne dass eine Datenbank dahinterstehen muss. +/// +public interface ITmdbSettingsSource +{ + Task GetAsync(CancellationToken ct = default); +} diff --git a/server/tests/Halo.Api.Tests/HaloPathsTests.cs b/server/tests/Halo.Api.Tests/HaloPathsTests.cs index d543127..dc145b2 100644 --- a/server/tests/Halo.Api.Tests/HaloPathsTests.cs +++ b/server/tests/Halo.Api.Tests/HaloPathsTests.cs @@ -3,20 +3,44 @@ namespace Halo.Api.Tests; /// Was beim allerersten Start passiert, bevor irgendetwas anderes greift. public class HaloPathsTests { - private string _wurzel = ""; + private static readonly string[] Variablen = ["HALO_DATA", "HALO_CACHE", "HALO_TRANSCODE"]; + private string _wurzel = ""; + private Dictionary _vorher = []; + + /// Merkt sich die Umgebung, bevor diese Klasse daran rührt. + /// + /// + /// Ohne das kostete der ganze Lauf eine Viertelstunde. Zwei Tests + /// hier löschen die Pfadvariablen, um den Rückfall auf die Voreinstellung + /// zu prüfen — und liessen sie gelöscht zurück. Jeder danach gestartete + /// Testserver fiel dann auf <Projektverzeichnis>/data zurück, + /// und Dutzende Server teilten sich **eine** SQLite-Datei. Die warteten + /// dann aufeinander, statt zu prüfen. + /// + /// Prozessweiter Zustand gehört zurückgesetzt, wo er verändert wurde. Das + /// ist keine Formsache: der Schaden zeigt sich erst in einer anderen Klasse. + /// [SetUp] - public void Anlegen() => + public void Anlegen() + { + _vorher = Variablen.ToDictionary(v => v, Environment.GetEnvironmentVariable); _wurzel = Directory.CreateDirectory( Path.Combine(Path.GetTempPath(), "halo-pfade-" + Guid.CreateVersion7().ToString("N"))).FullName; + } [TearDown] public void Aufraeumen() { // Zurücksetzen, sonst lässt sich das gesperrte Verzeichnis nicht löschen. - foreach (var d in Directory.EnumerateDirectories(_wurzel, "*", SearchOption.AllDirectories)) - File.SetUnixFileMode(d, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + if (!OperatingSystem.IsWindows()) + { + foreach (var d in Directory.EnumerateDirectories(_wurzel, "*", SearchOption.AllDirectories)) + File.SetUnixFileMode(d, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } try { Directory.Delete(_wurzel, recursive: true); } catch (IOException) { } + + foreach (var (name, wert) in _vorher) Environment.SetEnvironmentVariable(name, wert); } private HaloPaths Pfade(string? daten = null) => new() @@ -61,6 +85,7 @@ public class HaloPathsTests if (OperatingSystem.IsWindows()) Assert.Ignore("Dateirechte verhalten sich dort anders."); var gesperrt = Directory.CreateDirectory(Path.Combine(_wurzel, "gesperrt")).FullName; + if (OperatingSystem.IsWindows()) return; File.SetUnixFileMode(gesperrt, UnixFileMode.UserRead | UnixFileMode.UserExecute); var fehler = Assert.Throws(() => Pfade(daten: gesperrt).EnsureExists()); @@ -87,10 +112,7 @@ public class HaloPathsTests public void Die_Umgebung_sticht_die_Voreinstellung() { Environment.SetEnvironmentVariable("HALO_DATA", "/woanders"); - try - { - Assert.That(HaloPaths.FromEnvironment(_wurzel).Data, Is.EqualTo("/woanders")); - } - finally { Environment.SetEnvironmentVariable("HALO_DATA", null); } + Assert.That(HaloPaths.FromEnvironment(_wurzel).Data, Is.EqualTo("/woanders")); + // Zurückgesetzt wird im TearDown, für alle drei Variablen gemeinsam. } } diff --git a/server/tests/Halo.Api.Tests/HaloServer.cs b/server/tests/Halo.Api.Tests/HaloServer.cs index defbd17..0e07c67 100644 --- a/server/tests/Halo.Api.Tests/HaloServer.cs +++ b/server/tests/Halo.Api.Tests/HaloServer.cs @@ -2,6 +2,8 @@ using System.Net.Http.Headers; using System.Net.Http.Json; using Halo.Api.Endpoints; using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; namespace Halo.Api.Tests; @@ -15,12 +17,13 @@ namespace Halo.Api.Tests; /// public sealed class HaloServer : IDisposable { + private readonly WebApplicationFactory _grundlage; private readonly WebApplicationFactory _fabrik; private readonly string _verzeichnis; public HttpClient Client { get; } - public HaloServer() + public HaloServer(Action? dienste = null) { _verzeichnis = Path.Combine(Path.GetTempPath(), "halo-test-" + Guid.CreateVersion7().ToString("N")); Environment.SetEnvironmentVariable("HALO_DATA", Path.Combine(_verzeichnis, "config")); @@ -31,7 +34,17 @@ public sealed class HaloServer : IDisposable // Anmeldung. Environment.SetEnvironmentVariable("HALO_RATELIMIT_SIGNIN", "10000"); - _fabrik = new WebApplicationFactory(); + _grundlage = new WebApplicationFactory(); + _fabrik = _grundlage.WithWebHostBuilder(b => b.ConfigureServices(sammlung => + { + // **Ohne das dauert der ganze Lauf eine Viertelstunde.** Ein Host + // wartet beim Herunterfahren fünf Sekunden auf seine Dienste, und + // bei einem Testserver je Test summiert sich das auf mehr Zeit, + // als sämtliche Prüfungen zusammen brauchen. Hier gibt es nichts, + // worauf zu warten wäre. + sammlung.Configure(o => o.ShutdownTimeout = TimeSpan.FromMilliseconds(50)); + dienste?.Invoke(sammlung); + })); Client = _fabrik.CreateClient(); } @@ -62,6 +75,10 @@ public sealed class HaloServer : IDisposable public void Dispose() { _fabrik.Dispose(); + // Auch die Grundlage: `WithWebHostBuilder` gibt eine neue Fabrik + // zurück und lässt die alte stehen. Wer nur die neue entsorgt, häuft + // je Test einen Host an, der erst beim Prozessende abgeräumt wird. + _grundlage.Dispose(); try { Directory.Delete(_verzeichnis, recursive: true); } catch (IOException) { /* Aufräumen ist Kür */ } } } diff --git a/server/tests/Halo.Api.Tests/MetadataTests.cs b/server/tests/Halo.Api.Tests/MetadataTests.cs new file mode 100644 index 0000000..f198b99 --- /dev/null +++ b/server/tests/Halo.Api.Tests/MetadataTests.cs @@ -0,0 +1,340 @@ +using System.Net; +using System.Net.Http.Json; +using Halo.Api.Endpoints; +using Halo.Core.Metadata; +using Microsoft.Extensions.DependencyInjection; + +namespace Halo.Api.Tests; + +/// Ein Anbieter, der antwortet, was der Test vorgibt. +public sealed class VorgegebenerAnbieter : IMetadataProvider +{ + public bool Konfiguriert { get; set; } = true; + public Exception? Wirft { get; set; } + public MovieMetadata? Film { get; set; } + public SeriesMetadata? Serie { get; set; } + public EpisodeMetadata? Folge { get; set; } + public List Gefragt { get; } = []; + + public Task IsConfiguredAsync(CancellationToken ct = default) => Task.FromResult(Konfiguriert); + + public Task FindMovieAsync(string titel, int? jahr, CancellationToken ct = default) + { + Gefragt.Add($"film:{titel}:{jahr}"); + if (Wirft is not null) throw Wirft; + return Task.FromResult(Film); + } + + public Task FindSeriesAsync(string titel, int? jahr, CancellationToken ct = default) + { + Gefragt.Add($"serie:{titel}"); + if (Wirft is not null) throw Wirft; + return Task.FromResult(Serie); + } + + public Task FindEpisodeAsync(int serie, int staffel, int folge, CancellationToken ct = default) + { + Gefragt.Add($"folge:{serie}:{staffel}:{folge}"); + if (Wirft is not null) throw Wirft; + return Task.FromResult(Folge); + } +} + +/// Eine Bildablage, die nichts herunterlädt. +public sealed class VorgegebeneBilder : IImageStore +{ + public List Geholt { get; } = []; + + public Task StoreAsync(RemoteImage bild, CancellationToken ct = default) + { + Geholt.Add(bild.Url); + return Task.FromResult(new string('a', 64)); + } +} + +public class MetadataTests +{ + private string _bestand = ""; + + [SetUp] + public void Anlegen() => _bestand = Directory.CreateDirectory( + Path.Combine(Path.GetTempPath(), "halo-meta-" + Guid.CreateVersion7().ToString("N"))).FullName; + + [TearDown] + public void Aufraeumen() + { + try { Directory.Delete(_bestand, recursive: true); } catch (IOException) { } + } + + private void Datei(string relativ) + { + var voll = Path.Combine(_bestand, relativ); + Directory.CreateDirectory(Path.GetDirectoryName(voll)!); + File.WriteAllBytes(voll, new byte[1024]); + } + + private HaloServer MitAnbieter(VorgegebenerAnbieter anbieter, VorgegebeneBilder? bilder = null) => + new(dienste => + { + dienste.AddScoped(_ => anbieter); + dienste.AddScoped(_ => bilder ?? new VorgegebeneBilder()); + }); + + private async Task<(HttpClient Admin, Guid Library)> Einrichten(HaloServer server, string art) + { + var admin = await server.SetUpAndSignIn(); + var client = server.AlsAngemeldeter(admin.Token); + var angelegt = await client.PostAsJsonAsync("/api/v1/libraries", + new CreateLibraryRequest("Prüfbestand", art, _bestand)); + angelegt.EnsureSuccessStatusCode(); + return (client, (await angelegt.Content.ReadFromJsonAsync())!.Id); + } + + private static async Task Scannen(HttpClient client, Guid bibliothek) + { + (await client.PostAsync($"/api/v1/libraries/{bibliothek}/scan", null)).EnsureSuccessStatusCode(); + for (var i = 0; i < 100; i++) + { + var stand = await client.GetFromJsonAsync($"/api/v1/libraries/{bibliothek}/scan"); + if (stand!.State == "finished") return stand; + if (stand.State == "failed") Assert.Fail($"Gescheitert: {stand.Error}"); + await Task.Delay(50); + } + Assert.Fail("Nicht fertig geworden."); + return null!; + } + + [Test] + public async Task Der_Titel_aus_der_Quelle_ersetzt_den_aus_dem_Dateinamen() + { + Datei("The.Matrix.1999.1080p.mkv"); + var anbieter = new VorgegebenerAnbieter + { + Film = new MovieMetadata(603, "Matrix", 1999, "Neo zweifelt.", + new RemoteImage("https://bild/p.jpg"), new RemoteImage("https://bild/b.jpg")), + }; + var bilder = new VorgegebeneBilder(); + + using var server = MitAnbieter(anbieter, bilder); + var (client, bibliothek) = await Einrichten(server, "Movies"); + var stand = await Scannen(client, bibliothek); + + Assert.That(stand.Metadata!.MoviesMatched, Is.EqualTo(1)); + Assert.That(anbieter.Gefragt, Does.Contain("film:The Matrix:1999")); + + var filme = await client.GetFromJsonAsync>("/api/v1/movies"); + Assert.That(filme!.Items[0].Title, Is.EqualTo("Matrix")); + Assert.That(filme.Items[0].Overview, Is.EqualTo("Neo zweifelt.")); + Assert.That(filme.Items[0].PosterImage, Is.Not.Null); + Assert.That(bilder.Geholt, Is.EquivalentTo(new[] { "https://bild/p.jpg", "https://bild/b.jpg" })); + } + + /// Was die Quelle nicht kennt, bleibt trotzdem in der Mediathek. + [Test] + public async Task Ein_Film_ohne_Treffer_behaelt_seinen_Dateinamen() + { + Datei("Urlaub Kreta 2019.mkv"); + using var server = MitAnbieter(new VorgegebenerAnbieter { Film = null }); + var (client, bibliothek) = await Einrichten(server, "Movies"); + await Scannen(client, bibliothek); + + var filme = await client.GetFromJsonAsync>("/api/v1/movies"); + Assert.That(filme!.Total, Is.EqualTo(1)); + Assert.That(filme.Items[0].Title, Is.EqualTo("Urlaub Kreta")); + } + + /// Ohne Schlüssel läuft der Scan trotzdem durch. + /// + /// + /// Das ist der Normalfall beim allerersten Start: Bibliothek angelegt, + /// Schlüssel noch nicht. Die Mediathek muss dann aus Dateinamen bestehen + /// und nicht leer sein. + /// + [Test] + public async Task Ohne_Schluessel_bleibt_es_bei_den_Dateinamen() + { + Datei("The Matrix (1999).mkv"); + var anbieter = new VorgegebenerAnbieter { Konfiguriert = false }; + + using var server = MitAnbieter(anbieter); + var (client, bibliothek) = await Einrichten(server, "Movies"); + var stand = await Scannen(client, bibliothek); + + Assert.That(stand.Metadata!.Skipped, Is.True); + Assert.That(anbieter.Gefragt, Is.Empty); + Assert.That((await client.GetFromJsonAsync>("/api/v1/movies"))!.Total, Is.EqualTo(1)); + } + + /// Ein falscher Schlüssel steht im Scanbericht, nicht nur im Protokoll. + [Test] + public async Task Ein_zurueckgewiesener_Schluessel_erscheint_im_Bericht() + { + Datei("The Matrix (1999).mkv"); + var anbieter = new VorgegebenerAnbieter + { + Wirft = new MetadataCredentialsException("TMDB weist den hinterlegten Schlüssel zurück."), + }; + + using var server = MitAnbieter(anbieter); + var (client, bibliothek) = await Einrichten(server, "Movies"); + var stand = await Scannen(client, bibliothek); + + Assert.That(stand.Metadata!.Error, Does.Contain("Schlüssel")); + // Die Datei ist trotzdem da — das Nachschlagen scheiterte, nicht der Scan. + Assert.That((await client.GetFromJsonAsync>("/api/v1/movies"))!.Total, Is.EqualTo(1)); + } + + [Test] + public async Task Ein_zweiter_Lauf_fragt_nicht_noch_einmal() + { + Datei("The Matrix (1999).mkv"); + var anbieter = new VorgegebenerAnbieter + { + Film = new MovieMetadata(603, "Matrix", 1999, "Neo.", null, null), + }; + + using var server = MitAnbieter(anbieter); + var (client, bibliothek) = await Einrichten(server, "Movies"); + await Scannen(client, bibliothek); + var anzahlNachErstem = anbieter.Gefragt.Count; + await Scannen(client, bibliothek); + + Assert.That(anbieter.Gefragt, Has.Count.EqualTo(anzahlNachErstem), + "Ein zugeordneter Titel wird nicht erneut nachgeschlagen"); + } + + [Test] + public async Task Serien_und_Folgen_werden_nachgeschlagen() + { + Datei("Dark/Dark - S01E01.mkv"); + var anbieter = new VorgegebenerAnbieter + { + Serie = new SeriesMetadata(70523, "Dark", 2017, "Vier Familien.", null, null), + Folge = new EpisodeMetadata("Geheimnisse", "Ein Junge verschwindet.", null), + }; + + using var server = MitAnbieter(anbieter); + var (client, bibliothek) = await Einrichten(server, "Shows"); + var stand = await Scannen(client, bibliothek); + + Assert.That(stand.Metadata!.SeriesMatched, Is.EqualTo(1)); + Assert.That(stand.Metadata.EpisodesMatched, Is.EqualTo(1)); + Assert.That(anbieter.Gefragt, Does.Contain("folge:70523:1:1")); + + var serien = await client.GetFromJsonAsync>("/api/v1/series"); + Assert.That(serien!.Items[0].Overview, Is.EqualTo("Vier Familien.")); + + var folgen = await client.GetFromJsonAsync>( + $"/api/v1/series/{serien.Items[0].Id}/episodes"); + Assert.That(folgen!.Items[0].Title, Is.EqualTo("Geheimnisse")); + Assert.That(folgen.Items[0].Overview, Is.EqualTo("Ein Junge verschwindet.")); + } + + /// Folgen einer Serie ohne Zuordnung werden gar nicht erst gefragt. + [Test] + public async Task Ohne_zugeordnete_Serie_werden_keine_Folgen_gefragt() + { + Datei("Unbekannt/Unbekannt - S01E01.mkv"); + var anbieter = new VorgegebenerAnbieter { Serie = null }; + + using var server = MitAnbieter(anbieter); + var (client, bibliothek) = await Einrichten(server, "Shows"); + await Scannen(client, bibliothek); + + Assert.That(anbieter.Gefragt.Where(g => g.StartsWith("folge:")), Is.Empty); + } +} + +public class MetadataSettingsTests +{ + /// Der Schlüssel darf nie zurückkommen. + /// + /// + /// Er gäbe jedem Angemeldeten das TMDB-Kontingent des Betreibers in die + /// Hand. Zum Anzeigen genügt die Auskunft, dass einer da ist. + /// + [Test] + public async Task Der_Schluessel_kommt_nicht_zurueck() + { + using var server = new HaloServer(); + var admin = await server.SetUpAndSignIn(); + var client = server.AlsAngemeldeter(admin.Token); + + await client.PutAsJsonAsync("/api/v1/settings/metadata", + new MetadataSettingsRequest("geheimer-schluessel", "de-DE")); + + var antwort = await client.GetAsync("/api/v1/settings/metadata"); + var roh = await antwort.Content.ReadAsStringAsync(); + + Assert.That(roh, Does.Not.Contain("geheimer-schluessel")); + var stand = await antwort.Content.ReadFromJsonAsync(); + Assert.That(stand!.HasApiKey, Is.True); + Assert.That(stand.Language, Is.EqualTo("de-DE")); + } + + [Test] + public async Task Ohne_Schluessel_sagt_der_Server_das_auch() + { + using var server = new HaloServer(); + var admin = await server.SetUpAndSignIn(); + var stand = await server.AlsAngemeldeter(admin.Token) + .GetFromJsonAsync("/api/v1/settings/metadata"); + + Assert.That(stand!.HasApiKey, Is.False); + Assert.That(stand.Language, Is.EqualTo("en-US")); + } + + [TestCase("deutsch")] + [TestCase("de_DE")] + [TestCase("DE")] + [TestCase("de-de")] + public async Task Eine_unsinnige_Sprachkennung_wird_abgelehnt(string sprache) + { + using var server = new HaloServer(); + var admin = await server.SetUpAndSignIn(); + + var antwort = await server.AlsAngemeldeter(admin.Token) + .PutAsJsonAsync("/api/v1/settings/metadata", new MetadataSettingsRequest(null, sprache)); + + Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + } + + [Test] + public async Task Ein_gewoehnlicher_Benutzer_sieht_die_Einstellungen_nicht() + { + using var server = new HaloServer(); + var admin = await server.SetUpAndSignIn(); + await server.AlsAngemeldeter(admin.Token).PostAsJsonAsync("/api/v1/users", + new CreateUserRequest("gast", "auch-ein-langes-passwort", false)); + var gast = await server.SignIn("gast", "auch-ein-langes-passwort", "iPad", "g-1"); + + var antwort = await server.AlsAngemeldeter(gast.Token).GetAsync("/api/v1/settings/metadata"); + Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden)); + } +} + +public class ImageEndpointTests +{ + [Test] + public async Task Bilder_gibt_es_nur_fuer_Angemeldete() + { + using var server = new HaloServer(); + await server.SetUpAndSignIn(); + + var antwort = await server.Client.GetAsync($"/api/v1/images/{new string('a', 64)}"); + Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); + } + + /// Der Weg, der sonst die Datenbank herausgäbe. + [TestCase("..%2F..%2Fconfig%2Fhalo.db")] + [TestCase("nichtsechzigvierzeichen")] + [TestCase("ZZZZ567890123456789012345678901234567890123456789012345678901234")] + public async Task Ein_Schluessel_der_keiner_ist_fuehrt_ins_Leere(string versuch) + { + using var server = new HaloServer(); + var admin = await server.SetUpAndSignIn(); + + var antwort = await server.AlsAngemeldeter(admin.Token).GetAsync($"/api/v1/images/{versuch}"); + Assert.That(antwort.StatusCode, Is.AnyOf(HttpStatusCode.NotFound, HttpStatusCode.BadRequest)); + } +} diff --git a/server/tests/Halo.Core.Tests/LibraryWalkerTests.cs b/server/tests/Halo.Core.Tests/LibraryWalkerTests.cs index 47c3698..e30f5ea 100644 --- a/server/tests/Halo.Core.Tests/LibraryWalkerTests.cs +++ b/server/tests/Halo.Core.Tests/LibraryWalkerTests.cs @@ -13,8 +13,13 @@ public class LibraryWalkerTests [TearDown] public void Aufraeumen() { - foreach (var d in Directory.EnumerateDirectories(_wurzel, "*", SearchOption.AllDirectories)) - File.SetUnixFileMode(d, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + // Ein Test sperrt absichtlich einen Ordner; ohne Zurücksetzen liesse + // er sich nicht mehr löschen. + if (!OperatingSystem.IsWindows()) + { + foreach (var d in Directory.EnumerateDirectories(_wurzel, "*", SearchOption.AllDirectories)) + File.SetUnixFileMode(d, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } try { Directory.Delete(_wurzel, recursive: true); } catch (IOException) { } } @@ -94,6 +99,7 @@ public class LibraryWalkerTests Datei("Filme/Erreichbar (2000).mkv"); var gesperrt = Directory.CreateDirectory(Path.Combine(_wurzel, "Filme", "gesperrt")).FullName; File.WriteAllBytes(Path.Combine(gesperrt, "Versteckt (2001).mkv"), new byte[10]); + if (OperatingSystem.IsWindows()) return; File.SetUnixFileMode(gesperrt, UnixFileMode.None); var ergebnis = LibraryWalker.Walk(_wurzel); diff --git a/server/tests/Halo.Metadata.Tests/Attrappe.cs b/server/tests/Halo.Metadata.Tests/Attrappe.cs new file mode 100644 index 0000000..4d7276c --- /dev/null +++ b/server/tests/Halo.Metadata.Tests/Attrappe.cs @@ -0,0 +1,46 @@ +using System.Net; +using System.Text; + +namespace Halo.Metadata.Tests; + +/// Ein HTTP-Gegenüber, das antwortet, was der Test vorgibt. +/// +/// +/// Damit die Prüfungen ohne Netz und ohne Schlüssel laufen. Ein +/// Projekt, dessen Tests einen fremden Dienst brauchen, hat am Ende keine +/// Tests: sie scheitern, wenn das WLAN hakt, und irgendwann schaltet sie +/// jemand ab. +/// +public sealed class Attrappe(Func antwort) : HttpMessageHandler +{ + public List Aufrufe { get; } = []; + + protected override Task SendAsync(HttpRequestMessage anfrage, CancellationToken ct) + { + // AbsoluteUri und nicht ToString(): Letzteres entschlüsselt zur + // Anzeige wieder, und dann sähe eine richtig kodierte Anfrage falsch + // aus — der Test prüfte eine Darstellung statt dessen, was hinausgeht. + Aufrufe.Add(anfrage.RequestUri!.AbsoluteUri); + return Task.FromResult(antwort(anfrage)); + } + + public static Attrappe MitJson(string json, HttpStatusCode code = HttpStatusCode.OK) => + new(_ => new HttpResponseMessage(code) + { + Content = new StringContent(json, Encoding.UTF8, "application/json"), + }); + + public static Attrappe MitStatus(HttpStatusCode code) => new(_ => new HttpResponseMessage(code)); + + public static Attrappe DieWirft(Exception fehler) => new(_ => throw fehler); +} + +public sealed record FesteEinstellungen(TmdbSettings Werte) : ITmdbSettingsSource +{ + public Task GetAsync(CancellationToken ct = default) => Task.FromResult(Werte); + + public static FesteEinstellungen MitSchluessel(string sprache = "de-DE") => + new(new TmdbSettings("ein-schluessel", sprache)); + + public static readonly FesteEinstellungen Ohne = new(TmdbSettings.Unconfigured); +} diff --git a/server/tests/Halo.Metadata.Tests/Halo.Metadata.Tests.csproj b/server/tests/Halo.Metadata.Tests/Halo.Metadata.Tests.csproj new file mode 100644 index 0000000..a9ddc52 --- /dev/null +++ b/server/tests/Halo.Metadata.Tests/Halo.Metadata.Tests.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + latest + enable + enable + false + + + + + + + + + + + + + + + + + + + + diff --git a/server/tests/Halo.Metadata.Tests/ImageCacheTests.cs b/server/tests/Halo.Metadata.Tests/ImageCacheTests.cs new file mode 100644 index 0000000..262f2fa --- /dev/null +++ b/server/tests/Halo.Metadata.Tests/ImageCacheTests.cs @@ -0,0 +1,155 @@ +using System.Net; +using System.Text; +using Halo.Core.Metadata; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Halo.Metadata.Tests; + +public class ImageCacheTests +{ + private string _wurzel = ""; + + [SetUp] + public void Anlegen() => _wurzel = Directory.CreateDirectory( + Path.Combine(Path.GetTempPath(), "halo-bilder-" + Guid.CreateVersion7().ToString("N"))).FullName; + + [TearDown] + public void Aufraeumen() + { + try { Directory.Delete(_wurzel, recursive: true); } catch (IOException) { } + } + + private ImageCache Ablage(Attrappe attrappe) => + new(new HttpClient(attrappe), new ImageCacheOptions(_wurzel), NullLogger.Instance); + + private static Attrappe MitBild(byte[]? inhalt = null) => new(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(inhalt ?? Encoding.UTF8.GetBytes("ein bild")), + }); + + [Test] + public async Task Ein_Bild_wird_abgelegt_und_liegt_danach_da() + { + var ablage = Ablage(MitBild()); + var schlüssel = await ablage.StoreAsync(new RemoteImage("https://bild.example/a.jpg")); + + Assert.That(schlüssel, Is.Not.Null); + Assert.That(schlüssel, Has.Length.EqualTo(64)); + Assert.That(File.Exists(ablage.PfadFür(schlüssel!)), Is.True); + } + + /// Dasselbe Bild wird nicht zweimal geholt. + /// + /// + /// Bei einer Serie mit zehn Staffeln hängt dasselbe Plakat an jeder — ohne + /// diese Prüfung lüde der Server es zehnmal. + /// + [Test] + public async Task Dieselbe_Adresse_laedt_nur_einmal() + { + var attrappe = MitBild(); + var ablage = Ablage(attrappe); + + var erster = await ablage.StoreAsync(new RemoteImage("https://bild.example/a.jpg")); + var zweiter = await ablage.StoreAsync(new RemoteImage("https://bild.example/a.jpg")); + + Assert.That(zweiter, Is.EqualTo(erster)); + Assert.That(attrappe.Aufrufe, Has.Count.EqualTo(1)); + } + + [Test] + public async Task Verschiedene_Adressen_ergeben_verschiedene_Schluessel() + { + var ablage = Ablage(MitBild()); + var a = await ablage.StoreAsync(new RemoteImage("https://bild.example/a.jpg")); + var b = await ablage.StoreAsync(new RemoteImage("https://bild.example/b.jpg")); + Assert.That(a, Is.Not.EqualTo(b)); + } + + /// Die Prüfung, ohne die die Adresszeile ein Weg in den Container wäre. + /// + /// + /// Die wichtigste Prüfung dieser Datei. Der Schlüssel kommt aus + /// einer URL. Liesse er Pfadangaben durch, wäre + /// /api/v1/images/../../config/halo.db ein Weg, die Datenbank + /// herunterzuladen — samt aller Token-Fingerabdrücke. + /// + [TestCase("../../config/halo.db")] + [TestCase("..%2f..%2fconfig")] + [TestCase("/etc/passwd")] + [TestCase("abc")] + [TestCase("")] + [TestCase("GROSSBUCHSTABEN0123456789012345678901234567890123456789012345678")] + [TestCase("zzzz567890123456789012345678901234567890123456789012345678901234")] + public void Ein_Schluessel_der_keiner_ist_fuehrt_nirgendwohin(string versuch) + { + Assert.That(Ablage(MitBild()).PfadFür(versuch), Is.Null); + } + + [Test] + public async Task Ein_echter_Schluessel_fuehrt_unter_die_Wurzel() + { + var ablage = Ablage(MitBild()); + var schlüssel = await ablage.StoreAsync(new RemoteImage("https://bild.example/a.jpg")); + var pfad = Path.GetFullPath(ablage.PfadFür(schlüssel!)!); + + Assert.That(pfad, Does.StartWith(Path.GetFullPath(_wurzel))); + } + + [Test] + public async Task Ein_Fehlschlag_legt_nichts_ab() + { + var ablage = Ablage(Attrappe.MitStatus(HttpStatusCode.NotFound)); + Assert.That(await ablage.StoreAsync(new RemoteImage("https://bild.example/weg.jpg")), Is.Null); + } + + [Test] + public async Task Kein_Netz_wirft_nicht() + { + var ablage = Ablage(Attrappe.DieWirft(new HttpRequestException("kein Netz"))); + Assert.That(await ablage.StoreAsync(new RemoteImage("https://bild.example/a.jpg")), Is.Null); + } + + /// Nach einem Abbruch darf keine halbe Datei unter dem Schlüssel liegen. + /// + /// + /// Sie würde nie wieder nachgeladen — der Schlüssel ist ja vorhanden — und + /// das Plakat bliebe für immer kaputt. + /// + [Test] + public async Task Ein_abgebrochener_Download_hinterlaesst_keine_halbe_Datei() + { + var ablage = Ablage(new Attrappe(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StreamContent(new AbbrechenderStrom()), + })); + + var schlüssel = await ablage.StoreAsync(new RemoteImage("https://bild.example/kaputt.jpg")); + Assert.That(schlüssel, Is.Null); + + var übrig = Directory.Exists(Path.Combine(_wurzel, "images")) + ? Directory.GetFiles(Path.Combine(_wurzel, "images"), "*", SearchOption.AllDirectories) + : []; + Assert.That(übrig.Where(f => !f.EndsWith(".teil")), Is.Empty); + } + + private sealed class AbbrechenderStrom : Stream + { + private int _gelesen; + public override int Read(byte[] puffer, int versatz, int anzahl) + { + if (_gelesen++ > 0) throw new IOException("Verbindung abgebrochen"); + puffer[versatz] = 1; + return 1; + } + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position { get => 0; set => throw new NotSupportedException(); } + public override void Flush() { } + public override long Seek(long v, SeekOrigin o) => throw new NotSupportedException(); + public override void SetLength(long v) => throw new NotSupportedException(); + public override void Write(byte[] p, int v, int a) => throw new NotSupportedException(); + } +} diff --git a/server/tests/Halo.Metadata.Tests/TmdbClientTests.cs b/server/tests/Halo.Metadata.Tests/TmdbClientTests.cs new file mode 100644 index 0000000..fb29970 --- /dev/null +++ b/server/tests/Halo.Metadata.Tests/TmdbClientTests.cs @@ -0,0 +1,163 @@ +using System.Net; +using Halo.Core.Metadata; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Halo.Metadata.Tests; + +public class TmdbClientTests +{ + private static TmdbClient Client(Attrappe attrappe, ITmdbSettingsSource? einstellungen = null) => + new(new HttpClient(attrappe) { BaseAddress = new Uri(TmdbClient.BaseAddress) }, + einstellungen ?? FesteEinstellungen.MitSchluessel(), + NullLogger.Instance); + + private const string FilmTreffer = """ + {"results":[{"id":603,"title":"Matrix","release_date":"1999-03-30", + "overview":"Neo zweifelt an der Wirklichkeit.", + "poster_path":"/abc.jpg","backdrop_path":"/def.jpg"}]} + """; + + [Test] + public async Task Ohne_Schluessel_wird_nichts_gefragt() + { + var attrappe = Attrappe.MitJson(FilmTreffer); + var client = Client(attrappe, FesteEinstellungen.Ohne); + + Assert.That(await client.IsConfiguredAsync(), Is.False); + Assert.That(await client.FindMovieAsync("Matrix", 1999), Is.Null); + Assert.That(attrappe.Aufrufe, Is.Empty, "Ohne Schlüssel darf keine Anfrage hinausgehen"); + } + + [Test] + public async Task Ein_Film_wird_gelesen() + { + var film = await Client(Attrappe.MitJson(FilmTreffer)).FindMovieAsync("Matrix", 1999); + + Assert.That(film, Is.Not.Null); + Assert.That(film!.ProviderId, Is.EqualTo(603)); + Assert.That(film.Title, Is.EqualTo("Matrix")); + Assert.That(film.Year, Is.EqualTo(1999)); + Assert.That(film.Overview, Does.StartWith("Neo zweifelt")); + Assert.That(film.Poster!.Url, Is.EqualTo("https://image.tmdb.org/t/p/w500/abc.jpg")); + Assert.That(film.Backdrop!.Url, Is.EqualTo("https://image.tmdb.org/t/p/w1280/def.jpg")); + } + + [Test] + public async Task Titel_Jahr_und_Sprache_landen_in_der_Anfrage() + { + var attrappe = Attrappe.MitJson(FilmTreffer); + await Client(attrappe, FesteEinstellungen.MitSchluessel("de-DE")).FindMovieAsync("Der Untergang", 2004); + + var aufruf = attrappe.Aufrufe.Single(); + Assert.That(aufruf, Does.Contain("query=Der%20Untergang")); + Assert.That(aufruf, Does.Contain("year=2004")); + Assert.That(aufruf, Does.Contain("language=de-DE")); + } + + [Test] + public async Task Ohne_Jahr_wird_keines_mitgeschickt() + { + var attrappe = Attrappe.MitJson(FilmTreffer); + await Client(attrappe).FindMovieAsync("Matrix", null); + Assert.That(attrappe.Aufrufe.Single(), Does.Not.Contain("year=")); + } + + [Test] + public async Task Eine_leere_Trefferliste_ergibt_nichts() + { + Assert.That(await Client(Attrappe.MitJson("""{"results":[]}""")).FindMovieAsync("Gibtesnicht", null), Is.Null); + } + + /// Ein falscher Schlüssel muss auffallen. + /// + /// + /// „Nichts gefunden" und „dein Schlüssel ist falsch" sehen in der + /// Mediathek gleich aus. Ohne diese Unterscheidung sucht jemand + /// stundenlang nach dem Grund, warum keine Bilder erscheinen. + /// + [TestCase(HttpStatusCode.Unauthorized)] + [TestCase(HttpStatusCode.Forbidden)] + public void Ein_zurueckgewiesener_Schluessel_wirft(HttpStatusCode code) + { + var client = Client(Attrappe.MitStatus(code)); + var fehler = Assert.ThrowsAsync(() => client.FindMovieAsync("Matrix", null)); + Assert.That(fehler!.Message, Does.Contain("Schlüssel")); + } + + /// Alles andere ist kein Grund, den Scan abzubrechen. + [TestCase(HttpStatusCode.NotFound)] + [TestCase(HttpStatusCode.InternalServerError)] + [TestCase(HttpStatusCode.TooManyRequests)] + [TestCase(HttpStatusCode.ServiceUnavailable)] + public async Task Andere_Fehler_ergeben_still_nichts(HttpStatusCode code) + { + Assert.That(await Client(Attrappe.MitStatus(code)).FindMovieAsync("Matrix", null), Is.Null); + } + + /// Ein Anmeldeportal im Hotel-WLAN antwortet mit HTML und Status 200. + /// + /// + /// Darauf mit einer Ausnahme zu reagieren hiesse, den ganzen Scan an einem + /// fremden Netz scheitern zu lassen. + /// + [Test] + public async Task Unsinn_statt_JSON_wirft_nicht() + { + var attrappe = Attrappe.MitJson("Bitte anmelden"); + Assert.That(await Client(attrappe).FindMovieAsync("Matrix", null), Is.Null); + } + + [Test] + public async Task Kein_Netz_ergibt_nichts_statt_eines_Absturzes() + { + var attrappe = Attrappe.DieWirft(new HttpRequestException("Name or service not known")); + Assert.That(await Client(attrappe).FindMovieAsync("Matrix", null), Is.Null); + } + + [Test] + public async Task Eine_Serie_wird_gelesen() + { + var json = """ + {"results":[{"id":70523,"name":"Dark","first_air_date":"2017-12-01", + "overview":"Vier Familien.","poster_path":"/p.jpg","backdrop_path":null}]} + """; + var serie = await Client(Attrappe.MitJson(json)).FindSeriesAsync("Dark", null); + + Assert.That(serie!.ProviderId, Is.EqualTo(70523)); + Assert.That(serie.Title, Is.EqualTo("Dark")); + Assert.That(serie.Year, Is.EqualTo(2017)); + Assert.That(serie.Poster!.Url, Does.EndWith("/p.jpg")); + Assert.That(serie.Backdrop, Is.Null, "Ein fehlendes Bild ist null, keine kaputte Adresse"); + } + + [Test] + public async Task Eine_Folge_wird_gelesen() + { + var attrappe = Attrappe.MitJson(""" + {"name":"Geheimnisse","overview":"Ein Junge verschwindet.","still_path":"/s.jpg"} + """); + var folge = await Client(attrappe).FindEpisodeAsync(70523, 1, 1); + + Assert.That(folge!.Title, Is.EqualTo("Geheimnisse")); + Assert.That(folge.Still!.Url, Is.EqualTo("https://image.tmdb.org/t/p/w300/s.jpg")); + Assert.That(attrappe.Aufrufe.Single(), Does.Contain("tv/70523/season/1/episode/1")); + } + + /// Eine Folge, die TMDB nicht kennt, ist kein Fehler. + [Test] + public async Task Eine_unbekannte_Folge_ergibt_nichts() + { + Assert.That(await Client(Attrappe.MitStatus(HttpStatusCode.NotFound)).FindEpisodeAsync(1, 99, 99), Is.Null); + } + + [Test] + public async Task Leere_Felder_werden_zu_null_statt_zu_Leerstrings() + { + var json = """{"results":[{"id":1,"title":"Ohne","release_date":"","overview":"","poster_path":""}]}"""; + var film = await Client(Attrappe.MitJson(json)).FindMovieAsync("Ohne", null); + + Assert.That(film!.Overview, Is.Null); + Assert.That(film.Year, Is.Null); + Assert.That(film.Poster, Is.Null); + } +}