diff --git a/server/README.md b/server/README.md
index 92d349b..8a042bb 100644
--- a/server/README.md
+++ b/server/README.md
@@ -68,7 +68,7 @@ hilft.
## Entwickeln
```bash
-dotnet test # 238 Tests
+dotnet test # 293 Tests
dotnet run --project src/Halo.Api # auf http://localhost:5000
```
@@ -189,6 +189,62 @@ Voraus: kein Fortschrittsbalken, kein Fortsetzen, kein Springen. Dafür gibt es
| `HALO_FFMPEG` | Pfad zu ffmpeg | `ffmpeg` |
| `HALO_FFPROBE` | Pfad zu ffprobe | `ffprobe` |
+## Zuordnung korrigieren
+
+```
+GET /api/v1/movies/{id}/matches?query=&year= Vorschläge
+PUT /api/v1/movies/{id}/match { "tmdbId": 70523 }
+GET /api/v1/series/{id}/matches?query=&year=
+PUT /api/v1/series/{id}/match
+```
+
+**Weil Raten manchmal danebengeht.** Die Suche nach „Dark" lieferte „Dark
+Shadows" von 1966 — ohne Jahresangabe gewinnt bei TMDB der beliebteste
+Treffer. Dagegen hilft keine bessere Heuristik, sondern nur die Möglichkeit,
+es richtigzustellen.
+
+Eine Korrektur wird **festgeschrieben**: in der Datenbank, in der NFO-Datei
+und als `lockdata`. Danach sucht kein Lauf mehr danach, und ein Neuaufbau der
+Datenbank oder ein Umzug findet die Entscheidung wieder. Bei einer Serie
+werden die Folgen gleich mit neu geholt — die alten gehörten zur falschen
+Serie.
+
+Nur Administratoren, denn es ändert die Mediathek für alle.
+
+## Beiwerk im Medienordner
+
+Mit `HALO_WRITE_SIDECARS=true` legt Halo Plakate, Hintergründe und
+NFO-Dateien neben den Film:
+
+```
+Der Untergang (2004)/
+├── Der Untergang (2004).mkv
+├── poster.jpg
+├── fanart.jpg
+└── movie.nfo
+```
+
+Liegen mehrere Filme in einem Ordner, heisst das Beiwerk
+`Der Untergang (2004)-poster.jpg` — sonst überschrieben sie sich gegenseitig.
+Serien bekommen `tvshow.nfo` eine Ebene über dem Staffelordner, Folgen je eine
+NFO und ein Standbild neben der Datei.
+
+**Standardmässig aus**, und die Medieneinhängung muss dafür ohne `:ro`
+auskommen. Was Halo schreiben darf, steht als Erlaubnisliste im Code
+(`SidecarNames`): alles andere wird abgelehnt, und Mediendateien werden nie
+geändert, umbenannt oder gelöscht. Transcode-Zwischenstände kommen nie in
+einen Medienordner — die liegen unter `HALO_TRANSCODE`.
+
+**Gelesen wird auch ohne Schreiberlaubnis.** Eine vorhandene NFO ist eine
+Zuordnung, die jemand getroffen hat; Halo nimmt sie und sucht gar nicht erst.
+Dasselbe gilt für Ordnernamen der Form `Der Untergang (2004) [tmdbid-613]` —
+Halo liest sie, benennt aber **nichts** um. Umbenennen ist der einzige
+Eingriff, der eine Bibliothek wirklich zerlegen kann.
+
+| Variable | Bedeutung | Voreinstellung |
+|---|---|---|
+| `HALO_WRITE_SIDECARS` | Beiwerk in den Medienordner schreiben | `false` |
+
## Anmeldung am Fernseher
Ein Passwort auf der Apple-TV-Fernbedienung zu tippen ist eine Zumutung.
diff --git a/server/docker-compose.example.yml b/server/docker-compose.example.yml
index 8ead38f..4f690b2 100644
--- a/server/docker-compose.example.yml
+++ b/server/docker-compose.example.yml
@@ -15,6 +15,20 @@ services:
environment:
- TZ=Europe/Zurich
+ # Plakate, Hintergründe und NFO-Dateien neben den Film legen.
+ #
+ # **Standardmässig aus.** Dafür muss die Medieneinhängung unten ohne
+ # `:ro` stehen — und damit kann der Server dort im Grundsatz schreiben.
+ # Dass er nur Beiwerk anlegt, hängt an einer Erlaubnisliste im Code
+ # (`SidecarNames`): poster.jpg, fanart.jpg, logo.png, movie.nfo,
+ # tvshow.nfo, je Folge eine NFO und ein Standbild. Alles andere lehnt er
+ # ab, auch Mediendateien — die werden nie geändert, umbenannt oder
+ # gelöscht.
+ #
+ # Der Gewinn: dein Bestand wird tragbar. Ein Umzug, ein Neuaufbau der
+ # Datenbank oder ein zweiter Server finden Zuordnungen und Bilder wieder.
+ - HALO_WRITE_SIDECARS=true
+
# Wem die Dateien auf dem Wirt gehören. 1000:1000 ist die Voreinstellung
# im Image; wer andere Kennungen hat, trägt sie hier ein — `id -u` und
# `id -g` auf dem Wirt sagen, welche.
@@ -31,10 +45,19 @@ services:
# wo die Medien liegen — hier wird viel und kurzlebig geschrieben.
- /volume1/Transcode:/transcode
- # Die Medien. **Schreibgeschützt**, und das ist Absicht: Halo liest nur.
+ # Die Medien.
+ #
+ # **Ohne `:ro`, weil HALO_WRITE_SIDECARS oben an ist.** Wer das nicht
+ # will, setzt die Variable auf `false` und hängt wieder mit `:ro` ein —
+ # dann bleiben Bilder und Metadaten nur in Halos Cache, und der Bestand
+ # ist gegen jeden Zugriff des Servers geschützt.
+ #
+ # Transcode-Zwischenstände kommen hier **nie** her; die liegen auf dem
+ # eigenen Volume darüber.
+ #
# Was hier eingehängt wird, ist der Pfad, den du später in der Oberfläche
# als Bibliothek anlegst — also `/media`, nicht `/volume4/MYFLIX`.
- - /volume4/MYFLIX:/media:ro
+ - /volume4/MYFLIX:/media
# Hardware fürs Umrechnen. Intel wie AMD laufen über VAAPI an derselben
# Stelle. Fehlt die Zeile, rechnet Halo in Software — langsamer, aber es
diff --git a/server/src/Halo.Api/Endpoints/Contracts.cs b/server/src/Halo.Api/Endpoints/Contracts.cs
index 6779239..2066aa7 100644
--- a/server/src/Halo.Api/Endpoints/Contracts.cs
+++ b/server/src/Halo.Api/Endpoints/Contracts.cs
@@ -120,3 +120,15 @@ public sealed record DeliveryInfo(
string PackageState,
double Progress,
string? Error);
+
+// --- Zuordnung korrigieren -----------------------------------------------
+
+/// Ein Vorschlag, aus dem gewählt werden kann.
+public sealed record MatchCandidate(
+ int TmdbId, string Title, int? Year, string? Overview, string? PosterUrl);
+
+public sealed record ApplyMatchRequest(int TmdbId);
+
+/// Wie die Zuordnung jetzt aussieht.
+public sealed record MatchState(
+ int? TmdbId, string? ImdbId, bool Locked, string Title, int? Year);
diff --git a/server/src/Halo.Api/Endpoints/MatchEndpoints.cs b/server/src/Halo.Api/Endpoints/MatchEndpoints.cs
new file mode 100644
index 0000000..a6024e0
--- /dev/null
+++ b/server/src/Halo.Api/Endpoints/MatchEndpoints.cs
@@ -0,0 +1,88 @@
+using Halo.Core.Library;
+using Halo.Core.Metadata;
+using Halo.Data;
+using Microsoft.EntityFrameworkCore;
+
+namespace Halo.Api.Endpoints;
+
+/// Eine falsche Zuordnung von Hand richtigstellen.
+///
+///
+///
+/// Weil Raten manchmal danebengeht. Die Suche nach „Dark" lieferte
+/// „Dark Shadows" von 1966 — ohne Jahresangabe gewinnt bei TMDB der
+/// beliebteste Treffer. Dagegen hilft keine bessere Heuristik, sondern nur
+/// die Möglichkeit, es richtigzustellen.
+///
+///
+/// Eine Korrektur wird festgeschrieben. In der Datenbank und in
+/// der NFO-Datei neben dem Film, mit lockdata. Danach sucht kein Lauf
+/// mehr danach — und ein Neuaufbau der Datenbank oder ein Umzug auf einen
+/// anderen Server findet die Entscheidung wieder.
+///
+///
+public static class MatchEndpoints
+{
+ public static void MapMatching(this IEndpointRouteBuilder app)
+ {
+ app.MapGet("/api/v1/movies/{id:guid}/matches", async (
+ Guid id, HaloContext db, IMetadataProvider anbieter, string? query, int? year) =>
+ {
+ var film = await db.Movies.AsNoTracking().FirstOrDefaultAsync(m => m.Id == id);
+ if (film is null) return Results.NotFound();
+
+ // Ohne eigene Suchworte das, was aus dem Dateinamen gelesen wurde
+ // — nicht der angezeigte Titel, der ja gerade der falsche ist.
+ var vorschläge = await anbieter.SearchMoviesAsync(
+ string.IsNullOrWhiteSpace(query) ? film.ParsedTitle : query.Trim(), year ?? film.Year);
+
+ return Results.Ok(vorschläge.Select(v =>
+ new MatchCandidate(v.ProviderId, v.Title, v.Year, v.Overview, v.Poster?.Url)).ToList());
+ }).RequireAuthorization(HaloPolicies.Administrator);
+
+ app.MapPut("/api/v1/movies/{id:guid}/match", async (
+ Guid id, ApplyMatchRequest anfrage, HaloContext db,
+ IMetadataProvider anbieter, MatchApplier anwender) =>
+ {
+ var film = await db.Movies.FirstOrDefaultAsync(m => m.Id == id);
+ if (film is null) return Results.NotFound();
+
+ var gefunden = await anbieter.GetMovieAsync(anfrage.TmdbId);
+ if (gefunden is null)
+ return Results.Problem($"TMDB kennt den Film {anfrage.TmdbId} nicht.",
+ statusCode: StatusCodes.Status404NotFound);
+
+ await anwender.ApplyAsync(film, gefunden);
+ return Results.Ok(new MatchState(film.TmdbId, film.ImdbId, film.MatchLocked, film.Title, film.Year));
+ }).RequireAuthorization(HaloPolicies.Administrator);
+
+ app.MapGet("/api/v1/series/{id:guid}/matches", async (
+ Guid id, HaloContext db, IMetadataProvider anbieter, string? query, int? year) =>
+ {
+ var serie = await db.Series.AsNoTracking().FirstOrDefaultAsync(s => s.Id == id);
+ if (serie is null) return Results.NotFound();
+
+ var vorschläge = await anbieter.SearchSeriesAsync(
+ string.IsNullOrWhiteSpace(query) ? serie.ParsedTitle : query.Trim(), year ?? serie.Year);
+
+ return Results.Ok(vorschläge.Select(v =>
+ new MatchCandidate(v.ProviderId, v.Title, v.Year, v.Overview, v.Poster?.Url)).ToList());
+ }).RequireAuthorization(HaloPolicies.Administrator);
+
+ app.MapPut("/api/v1/series/{id:guid}/match", async (
+ Guid id, ApplyMatchRequest anfrage, HaloContext db,
+ IMetadataProvider anbieter, MatchApplier anwender) =>
+ {
+ var serie = await db.Series.FirstOrDefaultAsync(s => s.Id == id);
+ if (serie is null) return Results.NotFound();
+
+ var gefunden = await anbieter.GetSeriesAsync(anfrage.TmdbId);
+ if (gefunden is null)
+ return Results.Problem($"TMDB kennt die Serie {anfrage.TmdbId} nicht.",
+ statusCode: StatusCodes.Status404NotFound);
+
+ await anwender.ApplyAsync(serie, gefunden);
+ return Results.Ok(new MatchState(serie.TmdbId, serie.ImdbId, serie.MatchLocked, serie.Title, serie.Year));
+ }).RequireAuthorization(HaloPolicies.Administrator);
+ }
+}
diff --git a/server/src/Halo.Api/MatchApplier.cs b/server/src/Halo.Api/MatchApplier.cs
new file mode 100644
index 0000000..3b96660
--- /dev/null
+++ b/server/src/Halo.Api/MatchApplier.cs
@@ -0,0 +1,127 @@
+using Halo.Core.Library;
+using Halo.Core.Metadata;
+using Halo.Data;
+using Microsoft.EntityFrameworkCore;
+
+namespace Halo.Api;
+
+/// Schreibt eine korrigierte Zuordnung überallhin, wo sie hingehört.
+///
+///
+/// An drei Stellen, und alle drei sind nötig. In die Datenbank, damit
+/// es sofort stimmt. In die NFO-Datei, damit es einen Neuaufbau und einen
+/// Umzug übersteht. Und als Sperre, damit kein automatischer Lauf die
+/// Entscheidung wieder überstimmt.
+///
+public sealed class MatchApplier(
+ HaloContext db,
+ IMetadataProvider anbieter,
+ IImageStore bilder,
+ IImageReader bildLeser,
+ ISidecarStore beiwerk,
+ ILogger log)
+{
+ public async Task ApplyAsync(Movie film, MovieMetadata gefunden, CancellationToken ct = default)
+ {
+ film.TmdbId = gefunden.ProviderId;
+ film.ImdbId = gefunden.ImdbId;
+ film.Title = gefunden.Title;
+ film.SortTitleValue = SortTitle.For(gefunden.Title);
+ film.Year = gefunden.Year ?? film.Year;
+ film.Overview = gefunden.Overview;
+ film.PosterImage = await Ablegen(gefunden.Poster, ct);
+ film.BackdropImage = await Ablegen(gefunden.Backdrop, ct);
+ film.MatchLocked = true;
+ await db.SaveChangesAsync(ct);
+
+ var datei = await db.MediaFiles.AsNoTracking().FirstOrDefaultAsync(f => f.MovieId == film.Id, ct);
+ if (datei is null || !beiwerk.Enabled) return;
+
+ var allein = beiwerk.IsAloneInFolder(datei.Path);
+ await BildNeben(datei.Path, Name(allein, datei.Path, SidecarNames.MoviePoster), film.PosterImage, ct);
+ await BildNeben(datei.Path, Name(allein, datei.Path, SidecarNames.MovieBackdrop), film.BackdropImage, ct);
+ await beiwerk.WriteTextAsync(datei.Path, SidecarNames.MovieNfo, Nfo.Write(new MovieNfo(
+ film.Title, film.Year, film.Overview, new ProviderIds(film.TmdbId, film.ImdbId), true)), ct);
+
+ log.LogInformation("Zuordnung von Hand gesetzt: „{Titel}“ → TMDB {Kennung}", film.Title, film.TmdbId);
+ }
+
+ public async Task ApplyAsync(Series serie, SeriesMetadata gefunden, CancellationToken ct = default)
+ {
+ serie.TmdbId = gefunden.ProviderId;
+ serie.ImdbId = gefunden.ImdbId;
+ serie.Title = gefunden.Title;
+ serie.SortTitleValue = SortTitle.For(gefunden.Title);
+ serie.Year = gefunden.Year ?? serie.Year;
+ serie.Overview = gefunden.Overview;
+ serie.PosterImage = await Ablegen(gefunden.Poster, ct);
+ serie.BackdropImage = await Ablegen(gefunden.Backdrop, ct);
+ serie.MatchLocked = true;
+
+ // **Die Folgen gehörten zur alten Serie.** Ihre Beschreibungen und
+ // Standbilder sind jetzt falsch; sie bleiben stehen, bis sie durch die
+ // der richtigen Serie ersetzt sind.
+ var folgen = await db.Episodes.Where(f => f.SeriesId == serie.Id)
+ .OrderBy(f => f.SeasonNumber).ThenBy(f => f.EpisodeNumber).ToListAsync(ct);
+
+ foreach (var folge in folgen)
+ {
+ ct.ThrowIfCancellationRequested();
+ var neu = await anbieter.FindEpisodeAsync(gefunden.ProviderId, folge.SeasonNumber, folge.EpisodeNumber, ct);
+
+ folge.Title = neu?.Title;
+ folge.Overview = neu?.Overview;
+ folge.StillImage = await Ablegen(neu?.Still, ct);
+ }
+
+ await db.SaveChangesAsync(ct);
+ if (!beiwerk.Enabled) return;
+
+ await SerienBeiwerk(serie, folgen, ct);
+ log.LogInformation("Zuordnung von Hand gesetzt: „{Titel}“ → TMDB {Kennung}, {Anzahl} Folgen neu geholt",
+ serie.Title, serie.TmdbId, folgen.Count);
+ }
+
+ private async Task SerienBeiwerk(Series serie, List folgen, CancellationToken ct)
+ {
+ var folgenKennungen = folgen.Select(f => f.Id).ToList();
+ var dateien = await db.MediaFiles.AsNoTracking()
+ .Where(d => d.EpisodeId != null && folgenKennungen.Contains(d.EpisodeId!.Value))
+ .ToListAsync(ct);
+
+ if (dateien.Count == 0) return;
+
+ var staffel = Path.GetDirectoryName(dateien[0].Path);
+ var serienOrdner = staffel is null ? null : Path.GetDirectoryName(staffel);
+ if (serienOrdner is not null)
+ {
+ var anker = Path.Combine(serienOrdner, "egal.mkv");
+ await BildNeben(anker, SidecarNames.SeriesPoster, serie.PosterImage, ct);
+ await BildNeben(anker, SidecarNames.SeriesBackdrop, serie.BackdropImage, ct);
+ await beiwerk.WriteTextAsync(anker, SidecarNames.SeriesNfo, Nfo.Write(new SeriesNfo(
+ serie.Title, serie.Year, serie.Overview, new ProviderIds(serie.TmdbId, serie.ImdbId), true)), ct);
+ }
+
+ foreach (var datei in dateien)
+ {
+ var folge = folgen.First(f => f.Id == datei.EpisodeId);
+ await BildNeben(datei.Path, SidecarNames.EpisodeThumb(datei.Path), folge.StillImage, ct);
+ await beiwerk.WriteTextAsync(datei.Path, SidecarNames.EpisodeNfo(datei.Path), Nfo.Write(
+ new EpisodeNfo(folge.Title, folge.SeasonNumber, folge.EpisodeNumber, folge.Overview)), ct);
+ }
+ }
+
+ private async Task Ablegen(RemoteImage? bild, CancellationToken ct) =>
+ bild is null ? null : await bilder.StoreAsync(bild, ct);
+
+ private async Task BildNeben(string anker, string name, string? schlüssel, CancellationToken ct)
+ {
+ if (schlüssel is null) return;
+ await using var strom = await bildLeser.OpenAsync(schlüssel, ct);
+ if (strom is null) return;
+ await beiwerk.WriteStreamAsync(anker, name, strom, ct);
+ }
+
+ private static string Name(bool allein, string mediendatei, string beiwerkName) =>
+ allein ? beiwerkName : SidecarNames.Prefixed(mediendatei, beiwerkName);
+}
diff --git a/server/src/Halo.Api/Program.cs b/server/src/Halo.Api/Program.cs
index f51b1ec..776d9bf 100644
--- a/server/src/Halo.Api/Program.cs
+++ b/server/src/Halo.Api/Program.cs
@@ -67,6 +67,11 @@ builder.Services.AddSingleton(FfmpegOptions.FromEnvironment());
builder.Services.AddSingleton();
builder.Services.AddSingleton();
+// Beiwerk im Medienordner — standardmässig aus. In fremde Medienordner zu
+// schreiben ist nichts, was ein Server ungefragt tun sollte.
+builder.Services.AddSingleton(SidecarOptions.FromEnvironment());
+builder.Services.AddSingleton();
+
builder.Services.AddScoped();
// Metadaten. Der Anbieter steckt hinter einer Schnittstelle, damit alles, was
@@ -82,7 +87,9 @@ builder.Services.AddScoped(sp => sp.GetRequiredService(c => c.Timeout = TimeSpan.FromSeconds(30));
builder.Services.AddScoped(sp => sp.GetRequiredService());
+builder.Services.AddScoped(sp => sp.GetRequiredService());
builder.Services.AddScoped();
+builder.Services.AddScoped();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddProblemDetails();
@@ -109,6 +116,7 @@ app.MapLibraryContent();
app.MapSettings();
app.MapImages();
app.MapDelivery();
+app.MapMatching();
app.Run();
diff --git a/server/src/Halo.Core/Library/ISidecarStore.cs b/server/src/Halo.Core/Library/ISidecarStore.cs
new file mode 100644
index 0000000..6262b5f
--- /dev/null
+++ b/server/src/Halo.Core/Library/ISidecarStore.cs
@@ -0,0 +1,29 @@
+namespace Halo.Core.Library;
+
+/// Legt Beiwerk neben eine Mediendatei.
+///
+///
+/// Jede Umsetzung muss befragen.
+/// Die Erlaubnisliste ist der einzige Schutz des Bestands, seit die
+/// Einhängung schreibbar ist — eine Umsetzung, die sie übergeht, hebelt ihn
+/// aus.
+///
+public interface ISidecarStore
+{
+ /// Ob überhaupt geschrieben werden soll und darf.
+ bool Enabled { get; }
+
+ Task WriteTextAsync(string mediendatei, string beiwerkName, string inhalt, CancellationToken ct = default);
+
+ Task WriteStreamAsync(string mediendatei, string beiwerkName, Stream inhalt, CancellationToken ct = default);
+
+ Task ReadTextAsync(string mediendatei, string beiwerkName, CancellationToken ct = default);
+
+ /// Ob diese Datei allein in ihrem Ordner liegt.
+ ///
+ ///
+ /// Entscheidet, ob das Beiwerk schlicht poster.jpg heisst oder den
+ /// Dateinamen davor bekommt.
+ ///
+ bool IsAloneInFolder(string mediendatei);
+}
diff --git a/server/src/Halo.Core/Library/MediaEntities.cs b/server/src/Halo.Core/Library/MediaEntities.cs
index fe268bb..b334524 100644
--- a/server/src/Halo.Core/Library/MediaEntities.cs
+++ b/server/src/Halo.Core/Library/MediaEntities.cs
@@ -70,6 +70,18 @@ public sealed class Movie
///
public required string ParsedTitle { get; init; }
+ /// Das Jahr, wie es im Dateinamen stand.
+ ///
+ ///
+ /// Aus demselben Grund wie , und er wurde
+ /// teuer gelernt. Nach einer Korrektur von Hand stand im Ordner
+ /// „Dark (2017)", in der Datenbank aber 2004 — der nächste Scan suchte
+ /// nach (Dark, 2017), fand nichts, legte den Film neu an und löschte den
+ /// korrigierten als verwaist. Gerettet hat es nur die NFO-Datei. Dieses
+ /// Feld ändert sich nie.
+ ///
+ public int? ParsedYear { get; init; }
+
public required string Title { get; set; }
/// Ohne führenden Artikel — siehe .
@@ -81,6 +93,23 @@ public sealed class Movie
/// Die Kennung bei TMDB, sobald zugeordnet.
public int? TmdbId { get; set; }
+ /// Die Kennung bei IMDb — für die NFO-Datei.
+ ///
+ ///
+ /// Damit ein anderes Werkzeug denselben Titel wiederfindet, auch wenn es
+ /// TMDB gar nicht kennt.
+ ///
+ public string? ImdbId { get; set; }
+
+ /// Ob jemand diese Zuordnung von Hand festgelegt hat.
+ ///
+ ///
+ /// Dann sucht kein Lauf mehr danach. Steht auch als lockdata in der
+ /// NFO-Datei — so überlebt die Entscheidung einen Neuaufbau der Datenbank
+ /// und einen Umzug auf einen anderen Server.
+ ///
+ public bool MatchLocked { get; set; }
+
/// Schlüssel des zwischengespeicherten Plakats — siehe Bildablage.
///
///
@@ -110,11 +139,40 @@ public sealed class Series
///
public required string ParsedTitle { get; init; }
+ /// Das Jahr, wie es im Dateinamen stand.
+ ///
+ ///
+ /// Aus demselben Grund wie , und er wurde
+ /// teuer gelernt. Nach einer Korrektur von Hand stand im Ordner
+ /// „Dark (2017)", in der Datenbank aber 2004 — der nächste Scan suchte
+ /// nach (Dark, 2017), fand nichts, legte den Film neu an und löschte den
+ /// korrigierten als verwaist. Gerettet hat es nur die NFO-Datei. Dieses
+ /// Feld ändert sich nie.
+ ///
+ public int? ParsedYear { 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; }
+
+ /// Die Kennung bei IMDb — für die NFO-Datei.
+ ///
+ ///
+ /// Damit ein anderes Werkzeug denselben Titel wiederfindet, auch wenn es
+ /// TMDB gar nicht kennt.
+ ///
+ public string? ImdbId { get; set; }
+
+ /// Ob jemand diese Zuordnung von Hand festgelegt hat.
+ ///
+ ///
+ /// Dann sucht kein Lauf mehr danach. Steht auch als lockdata in der
+ /// NFO-Datei — so überlebt die Entscheidung einen Neuaufbau der Datenbank
+ /// und einen Umzug auf einen anderen Server.
+ ///
+ public bool MatchLocked { get; set; }
/// Schlüssel des zwischengespeicherten Plakats — siehe Bildablage.
///
///
diff --git a/server/src/Halo.Core/Library/Nfo.cs b/server/src/Halo.Core/Library/Nfo.cs
new file mode 100644
index 0000000..605268f
--- /dev/null
+++ b/server/src/Halo.Core/Library/Nfo.cs
@@ -0,0 +1,154 @@
+using System.Xml;
+using System.Xml.Linq;
+
+namespace Halo.Core.Library;
+
+public sealed record MovieNfo(
+ string Title, int? Year, string? Plot, ProviderIds Ids, bool Locked);
+
+public sealed record SeriesNfo(
+ string Title, int? Year, string? Plot, ProviderIds Ids, bool Locked);
+
+public sealed record EpisodeNfo(
+ string? Title, int Season, int Episode, string? Plot);
+
+/// Die NFO-Datei neben dem Film: schreiben und lesen.
+///
+///
+///
+/// Die Form stammt von Kodi und wird von Jellyfin, Plex und Emby
+/// gelesen. Dass sie hier erzeugt wird, macht den Bestand tragbar: ein
+/// Umzug auf einen anderen Server, ein Neuaufbau der Datenbank oder ein
+/// zweiter Scan finden dieselben Zuordnungen wieder.
+///
+///
+/// Kennungen stehen doppelt drin. Einmal als <uniqueid> —
+/// die neuere Form — und einmal als <tmdbid> und
+/// <imdbid>, weil ältere Werkzeuge nur die kennen. Zwei Zeilen
+/// mehr gegen die Frage, warum ein anderes Programm nichts findet.
+///
+///
+public static class Nfo
+{
+ /// Verhindert, dass ein Scan eine Korrektur von Hand überschreibt.
+ ///
+ ///
+ /// Kodi und Jellyfin kennen dieses Feld unter demselben Namen. Steht es
+ /// auf true, hat jemand die Zuordnung ausdrücklich festgelegt —
+ /// und dann hat kein automatischer Lauf daran etwas zu ändern.
+ ///
+ private const string Sperre = "lockdata";
+
+ public static string Write(MovieNfo film) => Dokument("movie", wurzel =>
+ {
+ wurzel.Add(new XElement("title", film.Title));
+ if (film.Year is { } jahr) wurzel.Add(new XElement("year", jahr));
+ if (film.Plot is { Length: > 0 }) wurzel.Add(new XElement("plot", film.Plot));
+ Kennungen(wurzel, film.Ids);
+ wurzel.Add(new XElement(Sperre, film.Locked ? "true" : "false"));
+ });
+
+ public static string Write(SeriesNfo serie) => Dokument("tvshow", wurzel =>
+ {
+ wurzel.Add(new XElement("title", serie.Title));
+ if (serie.Year is { } jahr) wurzel.Add(new XElement("year", jahr));
+ if (serie.Plot is { Length: > 0 }) wurzel.Add(new XElement("plot", serie.Plot));
+ Kennungen(wurzel, serie.Ids);
+ wurzel.Add(new XElement(Sperre, serie.Locked ? "true" : "false"));
+ });
+
+ public static string Write(EpisodeNfo folge) => Dokument("episodedetails", wurzel =>
+ {
+ if (folge.Title is { Length: > 0 }) wurzel.Add(new XElement("title", folge.Title));
+ wurzel.Add(new XElement("season", folge.Season));
+ wurzel.Add(new XElement("episode", folge.Episode));
+ if (folge.Plot is { Length: > 0 }) wurzel.Add(new XElement("plot", folge.Plot));
+ });
+
+ /// Liest Kennungen aus einer vorhandenen NFO.
+ ///
+ ///
+ /// Gibt bei unlesbarem Inhalt zurück statt
+ /// zu werfen: eine von Hand geschriebene Datei ist oft kaputt, und das
+ /// darf keinen Scan abbrechen.
+ ///
+ public static ProviderIds ReadIds(string xml)
+ {
+ var wurzel = Lesen(xml);
+ if (wurzel is null) return ProviderIds.None;
+
+ int? tmdb = null;
+ string? imdb = null;
+
+ foreach (var eindeutig in wurzel.Elements("uniqueid"))
+ {
+ var art = (string?)eindeutig.Attribute("type");
+ var wert = eindeutig.Value.Trim();
+ if (art is null || wert.Length == 0) continue;
+
+ if (art.Equals("tmdb", StringComparison.OrdinalIgnoreCase) && int.TryParse(wert, out var zahl))
+ tmdb ??= zahl;
+ else if (art.Equals("imdb", StringComparison.OrdinalIgnoreCase))
+ imdb ??= wert.ToLowerInvariant();
+ }
+
+ // Die ältere Form, falls die neuere fehlt.
+ if (tmdb is null && int.TryParse(wurzel.Element("tmdbid")?.Value.Trim(), out var alt)) tmdb = alt;
+ imdb ??= wurzel.Element("imdbid")?.Value.Trim() is { Length: > 0 } a ? a.ToLowerInvariant() : null;
+
+ return new ProviderIds(tmdb, imdb);
+ }
+
+ /// Ob diese Zuordnung von Hand festgelegt wurde.
+ public static bool ReadLocked(string xml) =>
+ Lesen(xml)?.Element(Sperre)?.Value.Trim()
+ .Equals("true", StringComparison.OrdinalIgnoreCase) == true;
+
+ private static void Kennungen(XElement wurzel, ProviderIds kennungen)
+ {
+ if (kennungen.Tmdb is { } tmdb)
+ {
+ wurzel.Add(new XElement("uniqueid", new XAttribute("type", "tmdb"),
+ new XAttribute("default", "true"), tmdb));
+ wurzel.Add(new XElement("tmdbid", tmdb));
+ }
+ if (kennungen.Imdb is { Length: > 0 } imdb)
+ {
+ wurzel.Add(new XElement("uniqueid", new XAttribute("type", "imdb"), imdb));
+ wurzel.Add(new XElement("imdbid", imdb));
+ }
+ }
+
+ private static string Dokument(string wurzelName, Action füllen)
+ {
+ var wurzel = new XElement(wurzelName);
+ füllen(wurzel);
+ return new XDocument(new XDeclaration("1.0", "utf-8", null), wurzel)
+ .ToString(SaveOptions.None)
+ .Insert(0, "\n");
+ }
+
+ /// Liest XML, ohne sich dabei etwas unterschieben zu lassen.
+ ///
+ ///
+ /// DTDs sind abgeschaltet und es wird nichts nachgeladen. Diese
+ /// Dateien liegen im Medienordner, und was dort liegt, hat Halo nicht
+ /// geschrieben — eine NFO mit einer externen Entität könnte sonst den
+ /// Server dazu bringen, fremde Dateien zu lesen und auszuliefern.
+ ///
+ private static XElement? Lesen(string xml)
+ {
+ if (string.IsNullOrWhiteSpace(xml)) return null;
+ try
+ {
+ using var leser = XmlReader.Create(new StringReader(xml), new XmlReaderSettings
+ {
+ DtdProcessing = DtdProcessing.Prohibit,
+ XmlResolver = null,
+ IgnoreWhitespace = true,
+ });
+ return XDocument.Load(leser).Root;
+ }
+ catch (XmlException) { return null; }
+ }
+}
diff --git a/server/src/Halo.Core/Library/ProviderIds.cs b/server/src/Halo.Core/Library/ProviderIds.cs
new file mode 100644
index 0000000..13c8324
--- /dev/null
+++ b/server/src/Halo.Core/Library/ProviderIds.cs
@@ -0,0 +1,47 @@
+using System.Text.RegularExpressions;
+
+namespace Halo.Core.Library;
+
+/// Kennungen bei Metadatenquellen, wie sie in Namen stehen können.
+public sealed record ProviderIds(int? Tmdb, string? Imdb)
+{
+ public static readonly ProviderIds None = new(null, null);
+ public bool Any => Tmdb is not null || Imdb is not null;
+}
+
+/// Liest Kennungen aus Ordner- und Dateinamen.
+///
+///
+///
+/// Die Form stammt von Kodi und wird von Jellyfin gelesen:
+/// Der Untergang (2004) [tmdbid-613]. Wer seine Ordner so benennt —
+/// von Hand oder über Radarr —, hat seine Zuordnung damit festgeschrieben,
+/// und Halo soll sie nicht erneut erraten.
+///
+///
+/// Halo benennt nichts um. Es liest nur. Festgeschrieben wird eine
+/// Korrektur in der NFO-Datei — dieselbe Wirkung, ohne den einen Eingriff,
+/// der eine Bibliothek wirklich zerlegen kann.
+///
+///
+public static partial class ProviderIdParser
+{
+ [GeneratedRegex(@"\[tmdbid-(?\d{1,9})\]", RegexOptions.IgnoreCase)]
+ private static partial Regex TmdbKlammer();
+
+ [GeneratedRegex(@"\[imdbid-(?tt\d{6,10})\]", RegexOptions.IgnoreCase)]
+ private static partial Regex ImdbKlammer();
+
+ /// Sucht im ganzen Pfad — der Ordner trägt sie meist, nicht die Datei.
+ public static ProviderIds FromPath(string pfad)
+ {
+ if (string.IsNullOrWhiteSpace(pfad)) return ProviderIds.None;
+
+ var tmdb = TmdbKlammer().Match(pfad);
+ var imdb = ImdbKlammer().Match(pfad);
+
+ return new ProviderIds(
+ tmdb.Success && int.TryParse(tmdb.Groups["id"].Value, out var zahl) ? zahl : null,
+ imdb.Success ? imdb.Groups["id"].Value.ToLowerInvariant() : null);
+ }
+}
diff --git a/server/src/Halo.Core/Library/SidecarNames.cs b/server/src/Halo.Core/Library/SidecarNames.cs
new file mode 100644
index 0000000..7ccc783
--- /dev/null
+++ b/server/src/Halo.Core/Library/SidecarNames.cs
@@ -0,0 +1,89 @@
+namespace Halo.Core.Library;
+
+/// Welche Dateien Halo neben eine Mediendatei legen darf — und sonst keine.
+///
+///
+///
+/// Eine Erlaubnisliste, und sie ist der ganze Schutz. Damit Plakate und
+/// Metadaten im Filmordner landen können, muss die Einhängung schreibbar
+/// sein — und damit kann der Server im Grundsatz alles dort anstellen. Dass
+/// er es nicht tut, hängt an dieser Liste und an der Prüfung darunter.
+///
+///
+/// Mediendateien werden nie angefasst. Nicht geändert, nicht
+/// umbenannt, nicht gelöscht. Es gibt im ganzen Projekt keine Stelle, die das
+/// könnte, und diese Liste ist der Grund: was nicht daraufsteht, wird
+/// abgelehnt — auch wenn jemand es ausdrücklich verlangt.
+///
+///
+/// Transcode-Zwischenstände gehören ausdrücklich nicht hierher. Die
+/// liegen unter HALO_TRANSCODE, auf einem eigenen Volume. Ein
+/// Medienordner ist kein Kratzplatz.
+///
+///
+public static class SidecarNames
+{
+ public const string MoviePoster = "poster.jpg";
+ public const string MovieBackdrop = "fanart.jpg";
+ public const string MovieLogo = "logo.png";
+ public const string MovieNfo = "movie.nfo";
+
+ public const string SeriesPoster = "poster.jpg";
+ public const string SeriesBackdrop = "fanart.jpg";
+ public const string SeriesNfo = "tvshow.nfo";
+
+ /// Was zu einer Folge gehört, hängt am Namen ihrer Datei.
+ ///
+ ///
+ /// Aus Dark - S01E01.mkv werden Dark - S01E01.nfo und
+ /// Dark - S01E01-thumb.jpg. So erwarten es Kodi, Jellyfin und Plex
+ /// — und so findet auch ein Mensch sie wieder.
+ ///
+ public static string EpisodeNfo(string mediendatei) =>
+ Path.GetFileNameWithoutExtension(mediendatei) + ".nfo";
+
+ public static string EpisodeThumb(string mediendatei) =>
+ Path.GetFileNameWithoutExtension(mediendatei) + "-thumb.jpg";
+
+ /// Beiwerk für einen Film, der sich den Ordner mit anderen teilt.
+ ///
+ ///
+ /// Sonst überschreiben sich zwei Filme gegenseitig das Plakat. Wer
+ /// je Film einen Ordner anlegt, bekommt die schlichten Namen; wer zehn
+ /// Filme nebeneinanderlegt, bekommt sie mit dem Dateinamen davor. Kodi und
+ /// Jellyfin lesen beide Formen.
+ ///
+ public static string Prefixed(string mediendatei, string beiwerk) =>
+ Path.GetFileNameWithoutExtension(mediendatei) + "-" + beiwerk;
+
+ private static readonly HashSet Feste = new(StringComparer.OrdinalIgnoreCase)
+ { MoviePoster, MovieBackdrop, MovieLogo, MovieNfo, SeriesNfo };
+
+ private static readonly string[] Endungen =
+ [".nfo", "-thumb.jpg", "-poster.jpg", "-fanart.jpg", "-logo.png"];
+
+ /// Ob dieser Dateiname geschrieben werden darf.
+ ///
+ ///
+ /// Geprüft wird der Name, nicht die Absicht. Ein Pfadbestandteil,
+ /// ein Verzeichniswechsel oder ein führender Punkt kommen hier nicht
+ /// durch — und „film.mkv" erst recht nicht, auch wenn es auf .nfo
+ /// endete.
+ ///
+ public static bool MayWrite(string dateiname)
+ {
+ if (string.IsNullOrWhiteSpace(dateiname)) return false;
+
+ // Kein Pfad, nur ein Name. Alles andere könnte aus dem Ordner führen.
+ if (dateiname.Contains('/') || dateiname.Contains('\\')) return false;
+ if (dateiname != Path.GetFileName(dateiname)) return false;
+ if (dateiname.StartsWith('.')) return false;
+ if (dateiname.Contains("..", StringComparison.Ordinal)) return false;
+
+ if (Feste.Contains(dateiname)) return true;
+
+ // Je Folge: beliebiger Rumpf, aber nur mit diesen Endungen.
+ return Endungen.Any(e => dateiname.EndsWith(e, StringComparison.OrdinalIgnoreCase)
+ && dateiname.Length > e.Length);
+ }
+}
diff --git a/server/src/Halo.Core/Metadata/IMetadataProvider.cs b/server/src/Halo.Core/Metadata/IMetadataProvider.cs
index a5a6ae0..995f22e 100644
--- a/server/src/Halo.Core/Metadata/IMetadataProvider.cs
+++ b/server/src/Halo.Core/Metadata/IMetadataProvider.cs
@@ -5,11 +5,11 @@ public sealed record RemoteImage(string Url);
public sealed record MovieMetadata(
int ProviderId, string Title, int? Year, string? Overview,
- RemoteImage? Poster, RemoteImage? Backdrop);
+ RemoteImage? Poster, RemoteImage? Backdrop, string? ImdbId = null);
public sealed record SeriesMetadata(
int ProviderId, string Title, int? Year, string? Overview,
- RemoteImage? Poster, RemoteImage? Backdrop);
+ RemoteImage? Poster, RemoteImage? Backdrop, string? ImdbId = null);
public sealed record EpisodeMetadata(string? Title, string? Overview, RemoteImage? Still);
@@ -43,6 +43,23 @@ public interface IMetadataProvider
Task FindSeriesAsync(string title, int? year, CancellationToken ct = default);
Task FindEpisodeAsync(int seriesProviderId, int season, int episode, CancellationToken ct = default);
+
+ /// Holt einen Titel über seine Kennung, ohne zu suchen.
+ ///
+ ///
+ /// Für alles, was bereits zugeordnet ist. Steht eine Kennung im
+ /// Ordnernamen oder in einer NFO, hat jemand die Frage schon beantwortet
+ /// — erneut zu suchen hiesse, seine Antwort zu überstimmen. Dasselbe gilt
+ /// nach einer Korrektur von Hand.
+ ///
+ Task GetMovieAsync(int providerId, CancellationToken ct = default);
+
+ Task GetSeriesAsync(int providerId, CancellationToken ct = default);
+
+ /// Mehrere Vorschläge zur Auswahl — für das Korrigieren von Hand.
+ Task> SearchMoviesAsync(string title, int? year, CancellationToken ct = default);
+
+ Task> SearchSeriesAsync(string title, int? year, CancellationToken ct = default);
}
/// Der Schlüssel stimmt nicht — das muss der Betreiber erfahren.
@@ -67,3 +84,14 @@ public interface IImageStore
/// Holt das Bild und gibt seinen Schlüssel zurück — oder null.
Task StoreAsync(RemoteImage bild, CancellationToken ct = default);
}
+
+/// Ein Bild aus der Ablage wieder herausgeben.
+///
+///
+/// Gebraucht, um ein zwischengespeichertes Plakat zusätzlich in den
+/// Medienordner zu legen — ohne es ein zweites Mal herunterzuladen.
+///
+public interface IImageReader
+{
+ Task OpenAsync(string schlüssel, CancellationToken ct = default);
+}
diff --git a/server/src/Halo.Data/HaloContext.cs b/server/src/Halo.Data/HaloContext.cs
index 9f478c4..3691051 100644
--- a/server/src/Halo.Data/HaloContext.cs
+++ b/server/src/Halo.Data/HaloContext.cs
@@ -121,6 +121,7 @@ public sealed class HaloContext(DbContextOptions options) : DbConte
e.HasIndex(x => new { x.LibraryId, x.SortTitleValue });
e.Property(x => x.Title).HasMaxLength(512);
e.Property(x => x.SortTitleValue).HasMaxLength(512);
+ e.Property(x => x.ImdbId).HasMaxLength(32);
e.HasOne().WithMany().HasForeignKey(x => x.LibraryId)
.OnDelete(DeleteBehavior.Cascade);
});
@@ -131,6 +132,7 @@ public sealed class HaloContext(DbContextOptions options) : DbConte
e.HasIndex(x => new { x.LibraryId, x.SortTitleValue });
e.Property(x => x.Title).HasMaxLength(512);
e.Property(x => x.SortTitleValue).HasMaxLength(512);
+ e.Property(x => x.ImdbId).HasMaxLength(32);
e.HasOne().WithMany().HasForeignKey(x => x.LibraryId)
.OnDelete(DeleteBehavior.Cascade);
});
diff --git a/server/src/Halo.Data/LibraryScanner.cs b/server/src/Halo.Data/LibraryScanner.cs
index 563b9a4..927405a 100644
--- a/server/src/Halo.Data/LibraryScanner.cs
+++ b/server/src/Halo.Data/LibraryScanner.cs
@@ -230,13 +230,15 @@ public sealed class LibraryScanner(
private Movie FilmFinden(List bekannt, Guid libraryId, MovieName name)
{
var treffer = bekannt.FirstOrDefault(m =>
- string.Equals(m.ParsedTitle, name.Title, StringComparison.OrdinalIgnoreCase) && m.Year == name.Year);
+ string.Equals(m.ParsedTitle, name.Title, StringComparison.OrdinalIgnoreCase)
+ && m.ParsedYear == name.Year);
if (treffer is not null) return treffer;
var neu = new Movie
{
LibraryId = libraryId,
ParsedTitle = name.Title,
+ ParsedYear = name.Year,
Title = name.Title,
SortTitleValue = SortTitle.For(name.Title),
Year = name.Year,
diff --git a/server/src/Halo.Data/MetadataEnricher.cs b/server/src/Halo.Data/MetadataEnricher.cs
index 41cb857..c77b6b9 100644
--- a/server/src/Halo.Data/MetadataEnricher.cs
+++ b/server/src/Halo.Data/MetadataEnricher.cs
@@ -1,3 +1,4 @@
+using Halo.Core.Library;
using Halo.Core.Metadata;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
@@ -27,6 +28,8 @@ public sealed class MetadataEnricher(
HaloContext db,
IMetadataProvider anbieter,
IImageStore bilder,
+ IImageReader bildLeser,
+ ISidecarStore beiwerk,
ILogger log)
{
public async Task EnrichAsync(Guid libraryId, CancellationToken ct = default)
@@ -65,23 +68,59 @@ public sealed class MetadataEnricher(
foreach (var film in offen)
{
ct.ThrowIfCancellationRequested();
- var gefunden = await anbieter.FindMovieAsync(film.Title, film.Year, ct);
+
+ var datei = await db.MediaFiles.FirstOrDefaultAsync(f => f.MovieId == film.Id, ct);
+ var (vorgegeben, festgelegt) = await VorhandeneZuordnung(datei, SidecarNames.MovieNfo, ct);
+
+ // **Eine vorhandene Kennung sticht die Suche.** Steht sie im
+ // Ordnernamen oder in einer NFO, hat jemand die Frage schon
+ // beantwortet — erneut zu suchen hiesse, ihn zu überstimmen.
+ var gefunden = vorgegeben.Tmdb is { } kennung
+ ? await anbieter.GetMovieAsync(kennung, ct)
+ : festgelegt
+ ? null // festgelegt, aber ohne Kennung: dann bleibt es, wie es ist
+ : await anbieter.FindMovieAsync(film.Title, film.Year, ct);
+
if (gefunden is null) continue;
film.TmdbId = gefunden.ProviderId;
+ film.ImdbId = gefunden.ImdbId;
film.Title = gefunden.Title;
- film.SortTitleValue = Core.Library.SortTitle.For(gefunden.Title);
- film.Year ??= gefunden.Year;
+ film.SortTitleValue = SortTitle.For(gefunden.Title);
+ film.Year = gefunden.Year ?? film.Year;
film.Overview = gefunden.Overview;
film.PosterImage = await BildAblegen(gefunden.Poster, ct);
film.BackdropImage = await BildAblegen(gefunden.Backdrop, ct);
+ film.MatchLocked = festgelegt;
zugeordnet++;
+
+ if (datei is not null) await FilmBeiwerk(film, datei, ct);
}
if (zugeordnet > 0) await db.SaveChangesAsync(ct);
return zugeordnet;
}
+ /// Liest eine Zuordnung, die schon dasteht.
+ ///
+ ///
+ /// Zwei Quellen, in dieser Reihenfolge: die NFO-Datei neben dem Film und
+ /// der Ordnername in der Form [tmdbid-613]. Die NFO gewinnt — sie
+ /// ist die ausdrücklichere Aussage.
+ ///
+ private async Task<(ProviderIds Kennungen, bool Festgelegt)> VorhandeneZuordnung(
+ MediaFile? datei, string nfoName, CancellationToken ct)
+ {
+ if (datei is null) return (ProviderIds.None, false);
+
+ var ausPfad = ProviderIdParser.FromPath(datei.Path);
+ var nfo = await beiwerk.ReadTextAsync(datei.Path, nfoName, ct);
+ if (nfo is null) return (ausPfad, false);
+
+ var ausNfo = Nfo.ReadIds(nfo);
+ return (new ProviderIds(ausNfo.Tmdb ?? ausPfad.Tmdb, ausNfo.Imdb ?? ausPfad.Imdb), Nfo.ReadLocked(nfo));
+ }
+
private async Task SerienZuordnen(Guid libraryId, CancellationToken ct)
{
var offen = await db.Series.Where(s => s.LibraryId == libraryId && s.TmdbId == null).ToListAsync(ct);
@@ -90,23 +129,75 @@ public sealed class MetadataEnricher(
foreach (var serie in offen)
{
ct.ThrowIfCancellationRequested();
- var gefunden = await anbieter.FindSeriesAsync(serie.Title, serie.Year, ct);
+
+ // Bei einer Serie liegt die NFO neben der Folge, also im
+ // Staffelordner — gesucht wird deshalb ab der ersten Folgendatei.
+ var folgenKennungen = await db.Episodes.Where(f => f.SeriesId == serie.Id)
+ .Select(f => f.Id).ToListAsync(ct);
+ var datei = await db.MediaFiles
+ .FirstOrDefaultAsync(f => f.EpisodeId != null && folgenKennungen.Contains(f.EpisodeId!.Value), ct);
+
+ var (vorgegeben, festgelegt) = await SerienZuordnungLesen(datei, ct);
+
+ var gefunden = vorgegeben.Tmdb is { } kennung
+ ? await anbieter.GetSeriesAsync(kennung, ct)
+ : festgelegt ? null : await anbieter.FindSeriesAsync(serie.Title, serie.Year, ct);
+
if (gefunden is null) continue;
serie.TmdbId = gefunden.ProviderId;
+ serie.ImdbId = gefunden.ImdbId;
serie.Title = gefunden.Title;
- serie.SortTitleValue = Core.Library.SortTitle.For(gefunden.Title);
- serie.Year ??= gefunden.Year;
+ serie.SortTitleValue = SortTitle.For(gefunden.Title);
+ serie.Year = gefunden.Year ?? serie.Year;
serie.Overview = gefunden.Overview;
serie.PosterImage = await BildAblegen(gefunden.Poster, ct);
serie.BackdropImage = await BildAblegen(gefunden.Backdrop, ct);
+ serie.MatchLocked = festgelegt;
zugeordnet++;
+
+ if (datei is not null) await SerienBeiwerk(serie, datei, ct);
}
if (zugeordnet > 0) await db.SaveChangesAsync(ct);
return zugeordnet;
}
+ /// Für Serien liegt die tvshow.nfo einen Ordner höher.
+ ///
+ ///
+ /// Die Folgen stecken in Staffelordnern; die Serienangaben gehören
+ /// daneben, nicht hinein. Deshalb wird von der Folgendatei aus eine Ebene
+ /// nach oben gegangen.
+ ///
+ private async Task<(ProviderIds, bool)> SerienZuordnungLesen(MediaFile? datei, CancellationToken ct)
+ {
+ if (datei is null) return (ProviderIds.None, false);
+
+ var ausPfad = ProviderIdParser.FromPath(datei.Path);
+
+ foreach (var kandidat in SerienOrdner(datei.Path))
+ {
+ var nfo = await beiwerk.ReadTextAsync(kandidat, SidecarNames.SeriesNfo, ct);
+ if (nfo is null) continue;
+
+ var ausNfo = Nfo.ReadIds(nfo);
+ return (new ProviderIds(ausNfo.Tmdb ?? ausPfad.Tmdb, ausNfo.Imdb ?? ausPfad.Imdb), Nfo.ReadLocked(nfo));
+ }
+
+ return (ausPfad, false);
+ }
+
+ /// Der Ordner der Folge und der darüber — mehr nicht.
+ private static IEnumerable SerienOrdner(string folgenPfad)
+ {
+ yield return folgenPfad;
+
+ var staffel = Path.GetDirectoryName(folgenPfad);
+ var serie = staffel is null ? null : Path.GetDirectoryName(staffel);
+ if (serie is not null) yield return Path.Combine(serie, "egal.mkv");
+ }
+
/// Folgen nachschlagen — aber nur bei Serien, die zugeordnet sind.
private async Task FolgenZuordnen(Guid libraryId, CancellationToken ct)
{
@@ -136,6 +227,9 @@ public sealed class MetadataEnricher(
folge.Overview = gefunden.Overview;
folge.StillImage = await BildAblegen(gefunden.Still, ct);
zugeordnet++;
+
+ var datei = await db.MediaFiles.FirstOrDefaultAsync(d => d.EpisodeId == folge.Id, ct);
+ if (datei is not null) await FolgenBeiwerk(folge, datei, ct);
}
if (zugeordnet > 0) await db.SaveChangesAsync(ct);
@@ -144,4 +238,63 @@ public sealed class MetadataEnricher(
private async Task BildAblegen(RemoteImage? bild, CancellationToken ct) =>
bild is null ? null : await bilder.StoreAsync(bild, ct);
+
+ // --- Beiwerk im Medienordner -----------------------------------------
+
+ private async Task FilmBeiwerk(Movie film, MediaFile datei, CancellationToken ct)
+ {
+ if (!beiwerk.Enabled) return;
+
+ var allein = beiwerk.IsAloneInFolder(datei.Path);
+ await BildNeben(datei, Name(allein, datei.Path, SidecarNames.MoviePoster), film.PosterImage, ct);
+ await BildNeben(datei, Name(allein, datei.Path, SidecarNames.MovieBackdrop), film.BackdropImage, ct);
+
+ await beiwerk.WriteTextAsync(datei.Path, SidecarNames.MovieNfo, Nfo.Write(new MovieNfo(
+ film.Title, film.Year, film.Overview,
+ new ProviderIds(film.TmdbId, film.ImdbId), film.MatchLocked)), ct);
+ }
+
+ private async Task SerienBeiwerk(Series serie, MediaFile folgenDatei, CancellationToken ct)
+ {
+ if (!beiwerk.Enabled) return;
+
+ // Eine Ebene über dem Staffelordner — dorthin gehören Serienangaben.
+ var staffel = Path.GetDirectoryName(folgenDatei.Path);
+ var serienOrdner = staffel is null ? null : Path.GetDirectoryName(staffel);
+ if (serienOrdner is null) return;
+
+ var anker = Path.Combine(serienOrdner, "egal.mkv");
+ await BildNeben(anker, SidecarNames.SeriesPoster, serie.PosterImage, ct);
+ await BildNeben(anker, SidecarNames.SeriesBackdrop, serie.BackdropImage, ct);
+
+ await beiwerk.WriteTextAsync(anker, SidecarNames.SeriesNfo, Nfo.Write(new SeriesNfo(
+ serie.Title, serie.Year, serie.Overview,
+ new ProviderIds(serie.TmdbId, serie.ImdbId), serie.MatchLocked)), ct);
+ }
+
+ private async Task FolgenBeiwerk(Episode folge, MediaFile datei, CancellationToken ct)
+ {
+ if (!beiwerk.Enabled) return;
+
+ await BildNeben(datei, SidecarNames.EpisodeThumb(datei.Path), folge.StillImage, ct);
+ await beiwerk.WriteTextAsync(datei.Path, SidecarNames.EpisodeNfo(datei.Path), Nfo.Write(
+ new EpisodeNfo(folge.Title, folge.SeasonNumber, folge.EpisodeNumber, folge.Overview)), ct);
+ }
+
+ private Task BildNeben(MediaFile datei, string name, string? schlüssel, CancellationToken ct) =>
+ BildNeben(datei.Path, name, schlüssel, ct);
+
+ private async Task BildNeben(string ankerPfad, string name, string? schlüssel, CancellationToken ct)
+ {
+ if (schlüssel is null) return;
+
+ // Aus der Ablage kopiert, nicht erneut geholt: das Bild liegt schon da.
+ await using var strom = await bildLeser.OpenAsync(schlüssel, ct);
+ if (strom is null) return;
+
+ await beiwerk.WriteStreamAsync(ankerPfad, name, strom, ct);
+ }
+
+ private static string Name(bool allein, string mediendatei, string beiwerkName) =>
+ allein ? beiwerkName : SidecarNames.Prefixed(mediendatei, beiwerkName);
}
diff --git a/server/src/Halo.Data/Migrations/20261003165210_Zuordnung.Designer.cs b/server/src/Halo.Data/Migrations/20261003165210_Zuordnung.Designer.cs
new file mode 100644
index 0000000..ef14929
--- /dev/null
+++ b/server/src/Halo.Data/Migrations/20261003165210_Zuordnung.Designer.cs
@@ -0,0 +1,498 @@
+//
+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("20261003165210_Zuordnung")]
+ partial class Zuordnung
+ {
+ ///
+ 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("BitRate")
+ .HasColumnType("INTEGER");
+
+ b.Property("Container")
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property("Duration")
+ .HasColumnType("INTEGER");
+
+ b.Property("EpisodeId")
+ .HasColumnType("TEXT");
+
+ b.Property("LibraryId")
+ .HasColumnType("TEXT");
+
+ b.Property("ModifiedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("MovieId")
+ .HasColumnType("TEXT");
+
+ b.Property("Path")
+ .IsRequired()
+ .HasMaxLength(1024)
+ .HasColumnType("TEXT");
+
+ b.Property("ProbedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("SeenAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("SizeBytes")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("EpisodeId");
+
+ b.HasIndex("MovieId");
+
+ b.HasIndex("LibraryId", "Path")
+ .IsUnique();
+
+ b.ToTable("MediaFiles");
+ });
+
+ modelBuilder.Entity("Halo.Core.Library.MediaStream", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("Channels")
+ .HasColumnType("INTEGER");
+
+ b.Property("Codec")
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property("Height")
+ .HasColumnType("INTEGER");
+
+ b.Property("Index")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsDefault")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsForced")
+ .HasColumnType("INTEGER");
+
+ b.Property("Kind")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("TEXT");
+
+ b.Property("Language")
+ .HasMaxLength(16)
+ .HasColumnType("TEXT");
+
+ b.Property("MediaFileId")
+ .HasColumnType("TEXT");
+
+ b.Property("Title")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("Width")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("MediaFileId", "Index")
+ .IsUnique();
+
+ b.ToTable("MediaStreams");
+ });
+
+ modelBuilder.Entity("Halo.Core.Library.Movie", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("AddedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("BackdropImage")
+ .HasColumnType("TEXT");
+
+ b.Property("ImdbId")
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property("LibraryId")
+ .HasColumnType("TEXT");
+
+ b.Property("MatchLocked")
+ .HasColumnType("INTEGER");
+
+ 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("ImdbId")
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property("LibraryId")
+ .HasColumnType("TEXT");
+
+ b.Property("MatchLocked")
+ .HasColumnType("INTEGER");
+
+ 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.MediaStream", b =>
+ {
+ b.HasOne("Halo.Core.Library.MediaFile", null)
+ .WithMany()
+ .HasForeignKey("MediaFileId")
+ .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/20261003165210_Zuordnung.cs b/server/src/Halo.Data/Migrations/20261003165210_Zuordnung.cs
new file mode 100644
index 0000000..2bb8f9c
--- /dev/null
+++ b/server/src/Halo.Data/Migrations/20261003165210_Zuordnung.cs
@@ -0,0 +1,62 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Halo.Data.Migrations
+{
+ ///
+ public partial class Zuordnung : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn(
+ name: "ImdbId",
+ table: "Series",
+ type: "TEXT",
+ maxLength: 32,
+ nullable: true);
+
+ migrationBuilder.AddColumn(
+ name: "MatchLocked",
+ table: "Series",
+ type: "INTEGER",
+ nullable: false,
+ defaultValue: false);
+
+ migrationBuilder.AddColumn(
+ name: "ImdbId",
+ table: "Movies",
+ type: "TEXT",
+ maxLength: 32,
+ nullable: true);
+
+ migrationBuilder.AddColumn(
+ name: "MatchLocked",
+ table: "Movies",
+ type: "INTEGER",
+ nullable: false,
+ defaultValue: false);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "ImdbId",
+ table: "Series");
+
+ migrationBuilder.DropColumn(
+ name: "MatchLocked",
+ table: "Series");
+
+ migrationBuilder.DropColumn(
+ name: "ImdbId",
+ table: "Movies");
+
+ migrationBuilder.DropColumn(
+ name: "MatchLocked",
+ table: "Movies");
+ }
+ }
+}
diff --git a/server/src/Halo.Data/Migrations/20261003165918_GeparstesJahr.Designer.cs b/server/src/Halo.Data/Migrations/20261003165918_GeparstesJahr.Designer.cs
new file mode 100644
index 0000000..b757341
--- /dev/null
+++ b/server/src/Halo.Data/Migrations/20261003165918_GeparstesJahr.Designer.cs
@@ -0,0 +1,504 @@
+//
+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("20261003165918_GeparstesJahr")]
+ partial class GeparstesJahr
+ {
+ ///
+ 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("BitRate")
+ .HasColumnType("INTEGER");
+
+ b.Property("Container")
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property("Duration")
+ .HasColumnType("INTEGER");
+
+ b.Property("EpisodeId")
+ .HasColumnType("TEXT");
+
+ b.Property("LibraryId")
+ .HasColumnType("TEXT");
+
+ b.Property("ModifiedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("MovieId")
+ .HasColumnType("TEXT");
+
+ b.Property("Path")
+ .IsRequired()
+ .HasMaxLength(1024)
+ .HasColumnType("TEXT");
+
+ b.Property("ProbedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("SeenAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("SizeBytes")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("EpisodeId");
+
+ b.HasIndex("MovieId");
+
+ b.HasIndex("LibraryId", "Path")
+ .IsUnique();
+
+ b.ToTable("MediaFiles");
+ });
+
+ modelBuilder.Entity("Halo.Core.Library.MediaStream", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("Channels")
+ .HasColumnType("INTEGER");
+
+ b.Property("Codec")
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property("Height")
+ .HasColumnType("INTEGER");
+
+ b.Property("Index")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsDefault")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsForced")
+ .HasColumnType("INTEGER");
+
+ b.Property("Kind")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("TEXT");
+
+ b.Property("Language")
+ .HasMaxLength(16)
+ .HasColumnType("TEXT");
+
+ b.Property("MediaFileId")
+ .HasColumnType("TEXT");
+
+ b.Property("Title")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("Width")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("MediaFileId", "Index")
+ .IsUnique();
+
+ b.ToTable("MediaStreams");
+ });
+
+ modelBuilder.Entity("Halo.Core.Library.Movie", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("AddedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("BackdropImage")
+ .HasColumnType("TEXT");
+
+ b.Property("ImdbId")
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property("LibraryId")
+ .HasColumnType("TEXT");
+
+ b.Property("MatchLocked")
+ .HasColumnType("INTEGER");
+
+ b.Property("Overview")
+ .HasColumnType("TEXT");
+
+ b.Property("ParsedTitle")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("ParsedYear")
+ .HasColumnType("INTEGER");
+
+ b.Property("PosterImage")
+ .HasColumnType("TEXT");
+
+ b.Property("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("ImdbId")
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property("LibraryId")
+ .HasColumnType("TEXT");
+
+ b.Property("MatchLocked")
+ .HasColumnType("INTEGER");
+
+ b.Property("Overview")
+ .HasColumnType("TEXT");
+
+ b.Property("ParsedTitle")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("ParsedYear")
+ .HasColumnType("INTEGER");
+
+ b.Property("PosterImage")
+ .HasColumnType("TEXT");
+
+ b.Property("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.MediaStream", b =>
+ {
+ b.HasOne("Halo.Core.Library.MediaFile", null)
+ .WithMany()
+ .HasForeignKey("MediaFileId")
+ .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/20261003165918_GeparstesJahr.cs b/server/src/Halo.Data/Migrations/20261003165918_GeparstesJahr.cs
new file mode 100644
index 0000000..315584e
--- /dev/null
+++ b/server/src/Halo.Data/Migrations/20261003165918_GeparstesJahr.cs
@@ -0,0 +1,38 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Halo.Data.Migrations
+{
+ ///
+ public partial class GeparstesJahr : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn(
+ name: "ParsedYear",
+ table: "Series",
+ type: "INTEGER",
+ nullable: true);
+
+ migrationBuilder.AddColumn(
+ name: "ParsedYear",
+ table: "Movies",
+ type: "INTEGER",
+ nullable: true);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "ParsedYear",
+ table: "Series");
+
+ migrationBuilder.DropColumn(
+ name: "ParsedYear",
+ table: "Movies");
+ }
+ }
+}
diff --git a/server/src/Halo.Data/Migrations/HaloContextModelSnapshot.cs b/server/src/Halo.Data/Migrations/HaloContextModelSnapshot.cs
index 9c8ba33..898d171 100644
--- a/server/src/Halo.Data/Migrations/HaloContextModelSnapshot.cs
+++ b/server/src/Halo.Data/Migrations/HaloContextModelSnapshot.cs
@@ -291,9 +291,16 @@ namespace Halo.Data.Migrations
b.Property("BackdropImage")
.HasColumnType("TEXT");
+ b.Property("ImdbId")
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
b.Property("LibraryId")
.HasColumnType("TEXT");
+ b.Property("MatchLocked")
+ .HasColumnType("INTEGER");
+
b.Property("Overview")
.HasColumnType("TEXT");
@@ -301,6 +308,9 @@ namespace Halo.Data.Migrations
.IsRequired()
.HasColumnType("TEXT");
+ b.Property("ParsedYear")
+ .HasColumnType("INTEGER");
+
b.Property("PosterImage")
.HasColumnType("TEXT");
@@ -339,9 +349,16 @@ namespace Halo.Data.Migrations
b.Property("BackdropImage")
.HasColumnType("TEXT");
+ b.Property("ImdbId")
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
b.Property("LibraryId")
.HasColumnType("TEXT");
+ b.Property("MatchLocked")
+ .HasColumnType("INTEGER");
+
b.Property("Overview")
.HasColumnType("TEXT");
@@ -349,6 +366,9 @@ namespace Halo.Data.Migrations
.IsRequired()
.HasColumnType("TEXT");
+ b.Property("ParsedYear")
+ .HasColumnType("INTEGER");
+
b.Property("PosterImage")
.HasColumnType("TEXT");
diff --git a/server/src/Halo.Media/SidecarWriter.cs b/server/src/Halo.Media/SidecarWriter.cs
new file mode 100644
index 0000000..cd76576
--- /dev/null
+++ b/server/src/Halo.Media/SidecarWriter.cs
@@ -0,0 +1,123 @@
+using Halo.Core.Library;
+using Microsoft.Extensions.Logging;
+
+namespace Halo.Media;
+
+/// Ob Halo überhaupt in Medienordner schreiben darf.
+///
+///
+/// Standardmässig aus. In fremde Medienordner zu schreiben ist nichts,
+/// was ein Server ungefragt tun sollte — und für viele Aufbauten ist die
+/// Einhängung ohnehin schreibgeschützt. Wer es will, setzt
+/// HALO_WRITE_SIDECARS=true und hängt die Medien ohne :ro ein.
+///
+public sealed record SidecarOptions(bool Enabled)
+{
+ public static SidecarOptions FromEnvironment() => new(
+ Environment.GetEnvironmentVariable("HALO_WRITE_SIDECARS")?.Trim()
+ .Equals("true", StringComparison.OrdinalIgnoreCase) == true);
+}
+
+/// Legt Plakate und NFO-Dateien neben die Mediendatei.
+///
+///
+///
+/// Jeder Schreibvorgang geht durch .
+/// Diese Liste ist der einzige Schutz des Bestands, seit die Einhängung
+/// schreibbar ist. Ein Name, der nicht daraufsteht, wirft — und zwar laut:
+/// das wäre ein Fehler im Programm, kein Zustand, den man stillschweigend
+/// übergehen darf.
+///
+///
+/// Geschrieben wird daneben und dann umbenannt. Bricht der Vorgang ab,
+/// liegt sonst ein halbes Plakat unter dem richtigen Namen — und wird nie
+/// wieder erneuert, weil die Datei ja da ist.
+///
+///
+public sealed class SidecarWriter(SidecarOptions optionen, ILogger log) : ISidecarStore
+{
+ public bool Enabled => optionen.Enabled;
+
+ public Task WriteTextAsync(string mediendatei, string beiwerkName, string inhalt, CancellationToken ct = default) =>
+ SchreibenAsync(mediendatei, beiwerkName, ziel => File.WriteAllTextAsync(ziel, inhalt, ct));
+
+ public Task WriteStreamAsync(string mediendatei, string beiwerkName, Stream inhalt, CancellationToken ct = default) =>
+ SchreibenAsync(mediendatei, beiwerkName, async ziel =>
+ {
+ await using var datei = File.Create(ziel);
+ await inhalt.CopyToAsync(datei, ct);
+ });
+
+ public async Task ReadTextAsync(string mediendatei, string beiwerkName, CancellationToken ct = default)
+ {
+ // **Gelesen wird auch, wenn nicht geschrieben werden darf.** Eine
+ // vorhandene NFO ist eine Zuordnung, die jemand getroffen hat — die
+ // zu übergehen, bloss weil Halo selbst nicht schreiben soll, wäre
+ // verkehrt herum.
+ Pruefen(beiwerkName);
+ var pfad = Pfad(mediendatei, beiwerkName);
+
+ try
+ {
+ return File.Exists(pfad) ? await File.ReadAllTextAsync(pfad, ct) : null;
+ }
+ catch (Exception fehler) when (fehler is IOException or UnauthorizedAccessException)
+ {
+ log.LogDebug(fehler, "{Pfad} liess sich nicht lesen.", pfad);
+ return null;
+ }
+ }
+
+ public bool IsAloneInFolder(string mediendatei)
+ {
+ var ordner = Path.GetDirectoryName(mediendatei);
+ if (ordner is null) return false;
+
+ try
+ {
+ return Directory.EnumerateFiles(ordner).Count(MediaExtensions.IsVideo) <= 1;
+ }
+ catch (Exception fehler) when (fehler is IOException or UnauthorizedAccessException)
+ {
+ // Im Zweifel die vorsichtige Form: lieber ein Plakat mit
+ // Dateinamen davor als eines, das ein fremdes überschreibt.
+ log.LogDebug(fehler, "{Ordner} liess sich nicht durchsehen.", ordner);
+ return false;
+ }
+ }
+
+ private async Task SchreibenAsync(string mediendatei, string beiwerkName, Func schreiben)
+ {
+ Pruefen(beiwerkName);
+ if (!optionen.Enabled) return;
+
+ var ziel = Pfad(mediendatei, beiwerkName);
+ var halbfertig = ziel + ".halo-tmp";
+
+ try
+ {
+ await schreiben(halbfertig);
+ File.Move(halbfertig, ziel, overwrite: true);
+ }
+ catch (Exception fehler) when (fehler is IOException or UnauthorizedAccessException)
+ {
+ // **Kein Abbruch.** Eine schreibgeschützte Einhängung oder eine
+ // volle Platte darf keinen Scan umwerfen — die Mediathek steht
+ // auch ohne Beiwerk.
+ log.LogWarning(fehler, "{Ziel} liess sich nicht schreiben.", ziel);
+ try { if (File.Exists(halbfertig)) File.Delete(halbfertig); } catch (IOException) { }
+ }
+ }
+
+ private static string Pfad(string mediendatei, string beiwerkName) =>
+ Path.Combine(Path.GetDirectoryName(mediendatei) ?? ".", beiwerkName);
+
+ private static void Pruefen(string beiwerkName)
+ {
+ if (SidecarNames.MayWrite(beiwerkName)) return;
+
+ throw new InvalidOperationException(
+ $"„{beiwerkName}“ steht nicht auf der Liste der Dateien, die Halo anlegen darf. " +
+ "Siehe SidecarNames — das ist der Schutz des Bestands, und er wird nicht umgangen.");
+ }
+}
diff --git a/server/src/Halo.Metadata/ImageCache.cs b/server/src/Halo.Metadata/ImageCache.cs
index e0e67db..8c8a32f 100644
--- a/server/src/Halo.Metadata/ImageCache.cs
+++ b/server/src/Halo.Metadata/ImageCache.cs
@@ -26,7 +26,7 @@ public sealed record ImageCacheOptions(string Root);
public sealed class ImageCache(
HttpClient http,
ImageCacheOptions optionen,
- ILogger log) : IImageStore
+ ILogger log) : IImageStore, IImageReader
{
/// Mehr als das ist kein Plakat, sondern ein Versehen.
private const long HöchstensBytes = 16 * 1024 * 1024;
@@ -91,6 +91,25 @@ public sealed class ImageCache(
return Path.Combine(optionen.Root, "images", schlüssel[..2], schlüssel + ".img");
}
+ /// Gibt ein abgelegtes Bild zum Lesen heraus.
+ ///
+ ///
+ /// Damit dasselbe Plakat zusätzlich in den Medienordner gelegt werden
+ /// kann, ohne es ein zweites Mal herunterzuladen.
+ ///
+ public Task OpenAsync(string schlüssel, CancellationToken ct = default)
+ {
+ var pfad = PfadFür(schlüssel);
+ if (pfad is null || !File.Exists(pfad)) return Task.FromResult(null);
+
+ try { return Task.FromResult(File.OpenRead(pfad)); }
+ catch (IOException fehler)
+ {
+ log.LogDebug(fehler, "{Pfad} liess sich nicht öffnen.", pfad);
+ return Task.FromResult(null);
+ }
+ }
+
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
index 0766bc7..2fe937d 100644
--- a/server/src/Halo.Metadata/TmdbClient.cs
+++ b/server/src/Halo.Metadata/TmdbClient.cs
@@ -58,30 +58,84 @@ public sealed class TmdbClient(
{
var treffer = await SuchenAsync("search/movie", title,
year is { } j ? ("year", j.ToString()) : null, m => m.Title, 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));
+ return treffer is null ? null : await MitImdb(Als(treffer, title), "movie", ct);
}
+ public async Task> SearchMoviesAsync(
+ string title, int? year, CancellationToken ct = default)
+ {
+ var alle = await AlleAsync("search/movie", title,
+ year is { } j ? ("year", j.ToString()) : null, ct);
+ return alle.Select(m => Als(m, title)).ToList();
+ }
+
+ public async Task GetMovieAsync(int providerId, CancellationToken ct = default)
+ {
+ var einstellung = await einstellungen.GetAsync(ct);
+ if (string.IsNullOrWhiteSpace(einstellung.ApiKey)) return null;
+
+ var film = await HolenAsync(
+ $"movie/{providerId}?language={Uri.EscapeDataString(einstellung.Language)}", einstellung, ct);
+ return film is null ? null : await MitImdb(Als(film, ""), "movie", ct);
+ }
+
+ private static MovieMetadata Als(TmdbMovie roh, string rückfall) => new(
+ roh.Id, roh.Title ?? rückfall, JahrAus(roh.ReleaseDate), Leer(roh.Overview),
+ Bild(roh.PosterPath, PosterSize), Bild(roh.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, s => s.Name, ct);
- if (treffer is null) return null;
+ return treffer is null ? null : await MitImdb(Als(treffer, title), "tv", ct);
+ }
- 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