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