From f98b995050ff388f001fc8bb9dc9630ea10d6581 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sat, 3 Oct 2026 19:01:26 +0200 Subject: [PATCH] =?UTF-8?q?Beiwerk=20im=20Medienordner,=20und=20eine=20Zuo?= =?UTF-8?q?rdnung=20l=C3=A4sst=20sich=20korrigieren?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plakate, Hintergründe und NFO-Dateien landen jetzt neben dem Film — und eine falsche Zuordnung lässt sich richtigstellen, ohne dass der nächste Scan sie wieder überschreibt. Der Untergang (2004)/ ├── Der Untergang (2004).mkv ├── poster.jpg ├── fanart.jpg └── movie.nfo Bei Serien liegt `tvshow.nfo` eine Ebene über dem Staffelordner, je Folge kommen NFO und Standbild daneben. Liegen mehrere Filme in einem Ordner, heisst das Beiwerk `Titel (Jahr)-poster.jpg` — sonst überschrieben sie sich gegenseitig. **Die Erlaubnisliste ist der ganze Schutz.** Damit das geht, muss die Medieneinhängung ohne `:ro` auskommen, und damit könnte der Server dort alles anstellen. Dass er es nicht tut, hängt an `SidecarNames`: sechs Arten von Datei, nichts sonst. Ein Name, der nicht daraufsteht, wirft — laut, nicht still, denn das wäre ein Fehler im Programm. Mediendateien werden nie geändert, umbenannt oder gelöscht; es gibt keine Stelle, die das könnte. Geprüft an echten Dateien, einschliesslich der Versuche mit `../`, mit absoluten Pfaden und mit einer Mediendatei als Ziel. Standardmässig **aus**. In fremde Medienordner zu schreiben ist nichts, was ein Server ungefragt tun sollte. **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 `[tmdbid-613]`. Halo liest sie und benennt **nichts** um: Umbenennen ist der einzige Eingriff, der eine Bibliothek wirklich zerlegen kann. Nebenbei: eine NFO aus dem Medienordner hat Halo nicht geschrieben. DTDs sind beim Lesen abgeschaltet — eine untergeschobene Datei mit externer Entität könnte sonst den Server dazu bringen, `/config/halo.db` einzulesen und als Titel auszuliefern. Mit Prüfung. --- **Der Fehler, den erst der echte Lauf zeigte** — und er ist die zweite Auflage eines schon einmal behobenen: Nach der Korrektur 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. Überlebt hat die Zuordnung nur, weil sie in der NFO stand — das System hat sich selbst gerettet, aber der Eintrag war ein anderer und das Jahr wieder falsch. Dieselbe Klasse wie bei `ParsedTitle`: zugeordnet wurde über einen Wert, den die Metadatenquelle verändert. Jetzt gibt es `ParsedYear`, und das ändert sich nie. Mit Prüfung, die genau diesen Ablauf festhält. --- Dazu von TMDB: die IMDb-Kennung wird nachgeschlagen, damit sie in der NFO steht — wer später auf ein anderes Werkzeug wechselt, findet denselben Titel wieder, auch wenn das TMDB gar nicht kennt. 308 Tests grün. Im Container nachgewiesen: Zuordnung falsch, korrigiert, zweiter Scan — derselbe Eintrag, dieselbe Kennung, Jahr bleibt. Co-Authored-By: Claude Opus 5 --- server/README.md | 58 +- server/docker-compose.example.yml | 27 +- server/src/Halo.Api/Endpoints/Contracts.cs | 12 + .../src/Halo.Api/Endpoints/MatchEndpoints.cs | 88 +++ server/src/Halo.Api/MatchApplier.cs | 127 +++++ server/src/Halo.Api/Program.cs | 8 + server/src/Halo.Core/Library/ISidecarStore.cs | 29 + server/src/Halo.Core/Library/MediaEntities.cs | 58 ++ server/src/Halo.Core/Library/Nfo.cs | 154 ++++++ server/src/Halo.Core/Library/ProviderIds.cs | 47 ++ server/src/Halo.Core/Library/SidecarNames.cs | 89 ++++ .../Halo.Core/Metadata/IMetadataProvider.cs | 32 +- server/src/Halo.Data/HaloContext.cs | 2 + server/src/Halo.Data/LibraryScanner.cs | 4 +- server/src/Halo.Data/MetadataEnricher.cs | 165 +++++- .../20261003165210_Zuordnung.Designer.cs | 498 +++++++++++++++++ .../Migrations/20261003165210_Zuordnung.cs | 62 +++ .../20261003165918_GeparstesJahr.Designer.cs | 504 ++++++++++++++++++ .../20261003165918_GeparstesJahr.cs | 38 ++ .../Migrations/HaloContextModelSnapshot.cs | 20 + server/src/Halo.Media/SidecarWriter.cs | 123 +++++ server/src/Halo.Metadata/ImageCache.cs | 21 +- server/src/Halo.Metadata/TmdbClient.cs | 106 +++- .../Halo.Api.Tests/AbgeschalteterAnbieter.cs | 12 + .../Halo.Api.Tests/MatchCorrectionTests.cs | 249 +++++++++ server/tests/Halo.Api.Tests/MetadataTests.cs | 26 + server/tests/Halo.Core.Tests/SidecarTests.cs | 223 ++++++++ .../Halo.Media.Tests/SidecarWriterTests.cs | 146 +++++ .../Halo.Metadata.Tests/TmdbClientTests.cs | 6 +- .../tests/Halo.Metadata.Tests/ZugangTests.cs | 16 +- 30 files changed, 2911 insertions(+), 39 deletions(-) create mode 100644 server/src/Halo.Api/Endpoints/MatchEndpoints.cs create mode 100644 server/src/Halo.Api/MatchApplier.cs create mode 100644 server/src/Halo.Core/Library/ISidecarStore.cs create mode 100644 server/src/Halo.Core/Library/Nfo.cs create mode 100644 server/src/Halo.Core/Library/ProviderIds.cs create mode 100644 server/src/Halo.Core/Library/SidecarNames.cs create mode 100644 server/src/Halo.Data/Migrations/20261003165210_Zuordnung.Designer.cs create mode 100644 server/src/Halo.Data/Migrations/20261003165210_Zuordnung.cs create mode 100644 server/src/Halo.Data/Migrations/20261003165918_GeparstesJahr.Designer.cs create mode 100644 server/src/Halo.Data/Migrations/20261003165918_GeparstesJahr.cs create mode 100644 server/src/Halo.Media/SidecarWriter.cs create mode 100644 server/tests/Halo.Api.Tests/MatchCorrectionTests.cs create mode 100644 server/tests/Halo.Core.Tests/SidecarTests.cs create mode 100644 server/tests/Halo.Media.Tests/SidecarWriterTests.cs 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> SearchSeriesAsync( + string title, int? year, CancellationToken ct = default) + { + var alle = await AlleAsync("search/tv", title, + year is { } j ? ("first_air_date_year", j.ToString()) : null, ct); + return alle.Select(s => Als(s, title)).ToList(); + } + + public async Task GetSeriesAsync(int providerId, CancellationToken ct = default) + { + var einstellung = await einstellungen.GetAsync(ct); + if (string.IsNullOrWhiteSpace(einstellung.ApiKey)) return null; + + var serie = await HolenAsync( + $"tv/{providerId}?language={Uri.EscapeDataString(einstellung.Language)}", einstellung, ct); + return serie is null ? null : await MitImdb(Als(serie, ""), "tv", ct); + } + + private static SeriesMetadata Als(TmdbSeries roh, string rückfall) => new( + roh.Id, roh.Name ?? rückfall, JahrAus(roh.FirstAirDate), Leer(roh.Overview), + Bild(roh.PosterPath, PosterSize), Bild(roh.BackdropPath, BackdropSize)); + + /// Holt die IMDb-Kennung nach — eine Anfrage je neuem Treffer. + /// + /// + /// Die Suche bei TMDB liefert sie nicht mit. Gebraucht wird sie für + /// die NFO-Datei: wer später auf ein anderes Werkzeug wechselt, findet + /// damit denselben Titel wieder, auch wenn das Werkzeug TMDB gar nicht + /// kennt. + /// + /// Scheitert der Nachschlag, bleibt sie leer — das ist kein Grund, den + /// ganzen Treffer wegzuwerfen. + /// + private async Task MitImdb(MovieMetadata film, string art, CancellationToken ct) => + film with { ImdbId = await ImdbAsync(art, film.ProviderId, ct) }; + + private async Task MitImdb(SeriesMetadata serie, string art, CancellationToken ct) => + serie with { ImdbId = await ImdbAsync(art, serie.ProviderId, ct) }; + + private async Task ImdbAsync(string art, int kennung, CancellationToken ct) + { + var einstellung = await einstellungen.GetAsync(ct); + if (string.IsNullOrWhiteSpace(einstellung.ApiKey)) return null; + + var fremde = await HolenAsync($"{art}/{kennung}/external_ids", einstellung, ct); + return Leer(fremde?.ImdbId); } public async Task FindEpisodeAsync( @@ -99,6 +153,22 @@ public sealed class TmdbClient( return new EpisodeMetadata(Leer(folge.Name), Leer(folge.Overview), Bild(folge.StillPath, StillSize)); } + /// Die ganze Trefferliste — für die Auswahl von Hand. + private async Task> AlleAsync( + 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 []; + + var abfrage = $"{pfad}?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, einstellung, ct); + // Gedeckelt: wer aus fünfzig Vorschlägen wählen soll, wählt gar nicht. + return antwort?.Results?.Take(10).ToList() ?? []; + } + private async Task SuchenAsync( string pfad, string titel, (string Name, string Wert)? zusatz, Func titelVon, CancellationToken ct) where T : class @@ -202,6 +272,8 @@ public sealed class TmdbClient( // Nur die Felder, die gebraucht werden. TMDB liefert dreissig weitere. private sealed record TmdbSearch([property: JsonPropertyName("results")] List? Results); + private sealed record TmdbExternalIds([property: JsonPropertyName("imdb_id")] string? ImdbId); + private sealed record TmdbMovie( [property: JsonPropertyName("id")] int Id, [property: JsonPropertyName("title")] string? Title, diff --git a/server/tests/Halo.Api.Tests/AbgeschalteterAnbieter.cs b/server/tests/Halo.Api.Tests/AbgeschalteterAnbieter.cs index 7400681..696fe5a 100644 --- a/server/tests/Halo.Api.Tests/AbgeschalteterAnbieter.cs +++ b/server/tests/Halo.Api.Tests/AbgeschalteterAnbieter.cs @@ -21,4 +21,16 @@ public sealed class AbgeschalteterAnbieter : IMetadataProvider public Task FindEpisodeAsync(int s, int st, int f, CancellationToken ct = default) => throw new InvalidOperationException("In diesem Test darf nichts nachgeschlagen werden."); + + public Task GetMovieAsync(int id, CancellationToken ct = default) => + throw new InvalidOperationException("In diesem Test darf nichts nachgeschlagen werden."); + + public Task GetSeriesAsync(int id, CancellationToken ct = default) => + throw new InvalidOperationException("In diesem Test darf nichts nachgeschlagen werden."); + + public Task> SearchMoviesAsync(string t, int? j, CancellationToken ct = default) => + throw new InvalidOperationException("In diesem Test darf nichts nachgeschlagen werden."); + + public Task> SearchSeriesAsync(string t, int? j, CancellationToken ct = default) => + throw new InvalidOperationException("In diesem Test darf nichts nachgeschlagen werden."); } diff --git a/server/tests/Halo.Api.Tests/MatchCorrectionTests.cs b/server/tests/Halo.Api.Tests/MatchCorrectionTests.cs new file mode 100644 index 0000000..2692054 --- /dev/null +++ b/server/tests/Halo.Api.Tests/MatchCorrectionTests.cs @@ -0,0 +1,249 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Http.Json; +using Halo.Api.Endpoints; +using Halo.Core.Library; +using Halo.Core.Metadata; +using Halo.Media; +using Microsoft.Extensions.DependencyInjection; + +namespace Halo.Api.Tests; + +/// Eine falsche Zuordnung richtigstellen — und sie festschreiben. +public class MatchCorrectionTests +{ + private string _bestand = ""; + private string _filmOrdner = ""; + private HaloServer? _server; + + private static string? Werkzeug(string name) => + (Environment.GetEnvironmentVariable("PATH") ?? "").Split(Path.PathSeparator) + .Select(o => o.Length == 0 ? null : Path.Combine(o, name)) + .FirstOrDefault(p => p is not null && File.Exists(p)); + + [SetUp] + public void Anlegen() + { + if (Werkzeug("ffmpeg") is null) Assert.Ignore("ffmpeg ist auf diesem Rechner nicht installiert."); + _bestand = Directory.CreateDirectory( + Path.Combine(Path.GetTempPath(), "halo-korrektur-" + Guid.CreateVersion7().ToString("N"))).FullName; + _filmOrdner = Directory.CreateDirectory(Path.Combine(_bestand, "Dark (2017)")).FullName; + + var start = new ProcessStartInfo(Werkzeug("ffmpeg")!) { RedirectStandardError = true, UseShellExecute = false }; + foreach (var a in new[] + { + "-y", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc=duration=1:size=160x120:rate=10", + "-c:v", "libx264", "-preset", "ultrafast", Path.Combine(_filmOrdner, "Dark (2017).mkv"), + }) start.ArgumentList.Add(a); + using var p = Process.Start(start)!; + p.StandardError.ReadToEnd(); + p.WaitForExit(); + } + + [TearDown] + public void Aufraeumen() + { + _server?.Dispose(); _server = null; + try { if (_bestand.Length > 0) Directory.Delete(_bestand, recursive: true); } catch (IOException) { } + } + + /// Ein Anbieter, der zwei verschiedene Filme kennt. + private sealed class ZweiTreffer : IMetadataProvider + { + public static readonly MovieMetadata Falsch = + new(1, "Dark Shadows", 1966, "Eine Fernsehserie von 1966.", null, null, "tt0059978"); + public static readonly MovieMetadata Richtig = + new(70523, "Dark", 2017, "Nach dem Verschwinden eines Kindes.", null, null, "tt5753856"); + + /// Ein Treffer mit anderem Jahr als im Dateinamen. + public static readonly MovieMetadata AnderesJahr = + new(613, "Der Untergang", 2004, "Berlin, April 1945.", null, null, "tt0363163"); + + public Task IsConfiguredAsync(CancellationToken ct = default) => Task.FromResult(true); + + // Die Suche liefert den beliebteren zuerst — genau wie TMDB. + public Task FindMovieAsync(string t, int? j, CancellationToken ct = default) => + Task.FromResult(Falsch); + + public Task> SearchMoviesAsync(string t, int? j, CancellationToken ct = default) => + Task.FromResult>([Falsch, Richtig]); + + public Task GetMovieAsync(int id, CancellationToken ct = default) => + Task.FromResult( + id == Richtig.ProviderId ? Richtig + : id == Falsch.ProviderId ? Falsch + : id == AnderesJahr.ProviderId ? AnderesJahr + : null); + + public Task FindSeriesAsync(string t, int? j, CancellationToken ct = default) => + Task.FromResult(null); + public Task> SearchSeriesAsync(string t, int? j, CancellationToken ct = default) => + Task.FromResult>([]); + public Task GetSeriesAsync(int id, CancellationToken ct = default) => + Task.FromResult(null); + public Task FindEpisodeAsync(int s, int st, int f, CancellationToken ct = default) => + Task.FromResult(null); + } + + private async Task<(HttpClient Client, MovieResponse Film)> Eingelesen(bool beiwerkSchreiben) + { + _server = new HaloServer(dienste => + { + dienste.AddScoped(); + dienste.AddSingleton( + new SidecarWriter(new SidecarOptions(beiwerkSchreiben), + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance)); + }); + + var admin = await _server.SetUpAndSignIn(); + var client = _server.AlsAngemeldeter(admin.Token); + + var angelegt = await client.PostAsJsonAsync("/api/v1/libraries", + new CreateLibraryRequest("Filme", "Movies", _bestand)); + angelegt.EnsureSuccessStatusCode(); + var bibliothek = (await angelegt.Content.ReadFromJsonAsync())!; + + (await client.PostAsync($"/api/v1/libraries/{bibliothek.Id}/scan", null)).EnsureSuccessStatusCode(); + for (var i = 0; i < 150; i++) + { + var stand = await client.GetFromJsonAsync($"/api/v1/libraries/{bibliothek.Id}/scan"); + if (stand!.State == "finished") break; + await Task.Delay(50); + } + + var filme = await client.GetFromJsonAsync>("/api/v1/movies"); + return (client, filme!.Items.Single()); + } + + [Test] + public async Task Der_Scan_ordnet_zuerst_falsch_zu() + { + var (_, film) = await Eingelesen(beiwerkSchreiben: false); + Assert.That(film.Title, Is.EqualTo("Dark Shadows"), "So sieht der Fehler aus, den es zu beheben gilt"); + } + + [Test] + public async Task Die_Vorschlagsliste_enthaelt_den_richtigen() + { + var (client, film) = await Eingelesen(beiwerkSchreiben: false); + + var vorschläge = await client.GetFromJsonAsync>($"/api/v1/movies/{film.Id}/matches"); + + Assert.That(vorschläge, Has.Count.EqualTo(2)); + Assert.That(vorschläge!.Select(v => v.Title), Does.Contain("Dark")); + } + + [Test] + public async Task Eine_Korrektur_greift_sofort() + { + var (client, film) = await Eingelesen(beiwerkSchreiben: false); + + var antwort = await client.PutAsJsonAsync($"/api/v1/movies/{film.Id}/match", new ApplyMatchRequest(70523)); + antwort.EnsureSuccessStatusCode(); + + var stand = await antwort.Content.ReadFromJsonAsync(); + Assert.That(stand!.Title, Is.EqualTo("Dark")); + Assert.That(stand.TmdbId, Is.EqualTo(70523)); + Assert.That(stand.ImdbId, Is.EqualTo("tt5753856")); + Assert.That(stand.Locked, Is.True); + + var danach = await client.GetFromJsonAsync>("/api/v1/movies"); + Assert.That(danach!.Items.Single().Title, Is.EqualTo("Dark")); + } + + /// Die Korrektur überlebt einen Neuaufbau der Datenbank. + /// + /// + /// Darum geht es bei der NFO-Datei. In der Datenbank allein wäre + /// die Entscheidung mit dem nächsten Umzug oder Neuaufbau verloren. + /// + [Test] + public async Task Eine_Korrektur_landet_in_der_NFO_Datei() + { + var (client, film) = await Eingelesen(beiwerkSchreiben: true); + (await client.PutAsJsonAsync($"/api/v1/movies/{film.Id}/match", new ApplyMatchRequest(70523))) + .EnsureSuccessStatusCode(); + + var nfo = Path.Combine(_filmOrdner, "movie.nfo"); + Assert.That(File.Exists(nfo), Is.True, "Die NFO muss neben dem Film liegen"); + + var inhalt = await File.ReadAllTextAsync(nfo); + var kennungen = Nfo.ReadIds(inhalt); + Assert.That(kennungen.Tmdb, Is.EqualTo(70523)); + Assert.That(kennungen.Imdb, Is.EqualTo("tt5753856")); + Assert.That(Nfo.ReadLocked(inhalt), Is.True, "Damit kein Lauf die Entscheidung überstimmt"); + Assert.That(inhalt, Does.Contain("Dark")); + } + + [Test] + public async Task Ohne_Schreiberlaubnis_bleibt_der_Ordner_unberuehrt() + { + var (client, film) = await Eingelesen(beiwerkSchreiben: false); + (await client.PutAsJsonAsync($"/api/v1/movies/{film.Id}/match", new ApplyMatchRequest(70523))) + .EnsureSuccessStatusCode(); + + Assert.That(Directory.GetFiles(_filmOrdner).Select(Path.GetFileName), + Is.EqualTo(new[] { "Dark (2017).mkv" })); + } + + [Test] + public async Task Eine_Kennung_die_es_nicht_gibt_wird_abgelehnt() + { + var (client, film) = await Eingelesen(beiwerkSchreiben: false); + + var antwort = await client.PutAsJsonAsync($"/api/v1/movies/{film.Id}/match", new ApplyMatchRequest(999999)); + Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); + } + + /// Eine Korrektur muss den nächsten Scan überstehen. + /// + /// + /// Live gefunden, und der Fehler war unangenehm. Nach der Korrektur + /// 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 — die Zuordnung überlebte nur, + /// weil sie in der NFO stand. Zugeordnet wird deshalb über + /// ParsedYear, das sich nie ändert. + /// + [Test] + public async Task Eine_Korrektur_ueberlebt_den_naechsten_Scan() + { + var (client, film) = await Eingelesen(beiwerkSchreiben: false); + + (await client.PutAsJsonAsync($"/api/v1/movies/{film.Id}/match", new ApplyMatchRequest(613))) + .EnsureSuccessStatusCode(); + + var bibliotheken = await client.GetFromJsonAsync>("/api/v1/libraries"); + (await client.PostAsync($"/api/v1/libraries/{bibliotheken!.Single().Id}/scan", null)) + .EnsureSuccessStatusCode(); + for (var i = 0; i < 150; i++) + { + var stand = await client.GetFromJsonAsync( + $"/api/v1/libraries/{bibliotheken.Single().Id}/scan"); + if (stand!.State == "finished") break; + await Task.Delay(50); + } + + var danach = await client.GetFromJsonAsync>("/api/v1/movies"); + Assert.That(danach!.Total, Is.EqualTo(1), "Kein zweiter Eintrag für denselben Film"); + + var geblieben = danach.Items.Single(); + Assert.That(geblieben.Id, Is.EqualTo(film.Id), "Derselbe Eintrag, nicht ein neuer"); + Assert.That(geblieben.Title, Is.EqualTo("Der Untergang")); + Assert.That(geblieben.Year, Is.EqualTo(2004), "Und nicht wieder 2017 aus dem Dateinamen"); + } + + [Test] + public async Task Nur_Administratoren_duerfen_korrigieren() + { + var (client, film) = await Eingelesen(beiwerkSchreiben: false); + await client.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) + .PutAsJsonAsync($"/api/v1/movies/{film.Id}/match", new ApplyMatchRequest(70523)); + + Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden)); + } +} diff --git a/server/tests/Halo.Api.Tests/MetadataTests.cs b/server/tests/Halo.Api.Tests/MetadataTests.cs index 898e1b9..9fb8531 100644 --- a/server/tests/Halo.Api.Tests/MetadataTests.cs +++ b/server/tests/Halo.Api.Tests/MetadataTests.cs @@ -38,6 +38,32 @@ public sealed class VorgegebenerAnbieter : IMetadataProvider if (Wirft is not null) throw Wirft; return Task.FromResult(Folge); } + + public Task GetMovieAsync(int id, CancellationToken ct = default) + { + Gefragt.Add($"filmId:{id}"); + if (Wirft is not null) throw Wirft; + return Task.FromResult(Film); + } + + public Task GetSeriesAsync(int id, CancellationToken ct = default) + { + Gefragt.Add($"serieId:{id}"); + if (Wirft is not null) throw Wirft; + return Task.FromResult(Serie); + } + + public Task> SearchMoviesAsync(string t, int? j, CancellationToken ct = default) + { + Gefragt.Add($"suchFilm:{t}"); + return Task.FromResult>(Film is null ? [] : [Film]); + } + + public Task> SearchSeriesAsync(string t, int? j, CancellationToken ct = default) + { + Gefragt.Add($"suchSerie:{t}"); + return Task.FromResult>(Serie is null ? [] : [Serie]); + } } /// Eine Bildablage, die nichts herunterlädt. diff --git a/server/tests/Halo.Core.Tests/SidecarTests.cs b/server/tests/Halo.Core.Tests/SidecarTests.cs new file mode 100644 index 0000000..dbec577 --- /dev/null +++ b/server/tests/Halo.Core.Tests/SidecarTests.cs @@ -0,0 +1,223 @@ +using Halo.Core.Library; + +namespace Halo.Core.Tests; + +/// Was Halo in einen Medienordner schreiben darf. +/// +/// +/// Die wichtigste Prüfdatei des Projekts. Damit Plakate neben dem Film +/// landen können, ist die Einhängung schreibbar — und damit hängt der Schutz +/// des gesamten Bestands an dieser einen Liste. +/// +public class SidecarNamesTests +{ + [TestCase("poster.jpg")] + [TestCase("fanart.jpg")] + [TestCase("logo.png")] + [TestCase("movie.nfo")] + [TestCase("tvshow.nfo")] + [TestCase("Dark - S01E01.nfo")] + [TestCase("Dark - S01E01-thumb.jpg")] + [TestCase("POSTER.JPG")] + public void Beiwerk_darf_geschrieben_werden(string name) + { + Assert.That(SidecarNames.MayWrite(name), Is.True); + } + + /// Eine Mediendatei wird nie angefasst — auch nicht, wenn jemand es verlangt. + [TestCase("film.mkv")] + [TestCase("Der Untergang (2004).mp4")] + [TestCase("film.mkv.nfo.mkv")] + public void Mediendateien_niemals(string name) + { + Assert.That(SidecarNames.MayWrite(name), Is.False); + } + + /// Kein Weg aus dem Ordner heraus. + /// + /// + /// Käme hier ein Pfadbestandteil durch, liesse sich über einen + /// erfundenen „Dateinamen" in jedes Verzeichnis des Containers + /// schreiben — auch in das, in dem die Datenbank liegt. + /// + [TestCase("../poster.jpg")] + [TestCase("../../etc/passwd")] + [TestCase("/etc/poster.jpg")] + [TestCase("unterordner/poster.jpg")] + [TestCase("..\\poster.jpg")] + [TestCase("ordner\\movie.nfo")] + [TestCase("..movie.nfo")] + public void Kein_Pfad_kommt_durch(string versuch) + { + Assert.That(SidecarNames.MayWrite(versuch), Is.False); + } + + [TestCase("")] + [TestCase(" ")] + [TestCase(".nfo")] + [TestCase("-thumb.jpg")] + [TestCase(".versteckt.nfo")] + [TestCase("poster.png")] + [TestCase("beliebig.txt")] + public void Alles_andere_wird_abgelehnt(string versuch) + { + Assert.That(SidecarNames.MayWrite(versuch), Is.False); + } + + /// Zwei Filme im selben Ordner dürfen sich nicht überschreiben. + [Test] + public void Beiwerk_mit_Dateinamen_davor_ist_auch_erlaubt() + { + Assert.That(SidecarNames.Prefixed("Matrix (1999).mkv", SidecarNames.MoviePoster), + Is.EqualTo("Matrix (1999)-poster.jpg")); + + foreach (var beiwerk in new[] { SidecarNames.MoviePoster, SidecarNames.MovieBackdrop, SidecarNames.MovieLogo }) + Assert.That(SidecarNames.MayWrite(SidecarNames.Prefixed("Matrix (1999).mkv", beiwerk)), Is.True); + } + + [Test] + public void Folgen_Beiwerk_heisst_wie_die_Folge() + { + Assert.That(SidecarNames.EpisodeNfo("Dark - S01E01.mkv"), Is.EqualTo("Dark - S01E01.nfo")); + Assert.That(SidecarNames.EpisodeThumb("Dark - S01E01.mkv"), Is.EqualTo("Dark - S01E01-thumb.jpg")); + } + + /// Was erzeugt wird, muss auch erlaubt sein. + /// + /// + /// Die beiden Hälften könnten auseinanderlaufen — jemand ändert das Muster + /// für Folgen und vergisst die Liste. Dann schriebe Halo nichts mehr und + /// niemand wüsste, warum. + /// + [TestCase("Dark - S01E01.mkv")] + [TestCase("Notruf.Hafenkante.S01E01.German.1080p.mkv")] + [TestCase("Serie 1x01.avi")] + public void Die_erzeugten_Namen_stehen_auf_der_Liste(string mediendatei) + { + Assert.That(SidecarNames.MayWrite(SidecarNames.EpisodeNfo(mediendatei)), Is.True); + Assert.That(SidecarNames.MayWrite(SidecarNames.EpisodeThumb(mediendatei)), Is.True); + } +} + +public class ProviderIdParserTests +{ + [Test] + public void Eine_TMDB_Kennung_im_Ordnernamen_wird_gelesen() + { + var kennungen = ProviderIdParser.FromPath("/media/Filme/Der Untergang (2004) [tmdbid-613]/film.mkv"); + Assert.That(kennungen.Tmdb, Is.EqualTo(613)); + } + + [Test] + public void Eine_IMDb_Kennung_wird_gelesen_und_kleingeschrieben() + { + var kennungen = ProviderIdParser.FromPath("/media/Filme/Matrix (1999) [imdbid-TT0133093]/film.mkv"); + Assert.That(kennungen.Imdb, Is.EqualTo("tt0133093")); + } + + [Test] + public void Beide_nebeneinander_gehen_auch() + { + var kennungen = ProviderIdParser.FromPath("/m/Film (2004) [tmdbid-613] [imdbid-tt0363163]/f.mkv"); + Assert.That((kennungen.Tmdb, kennungen.Imdb), Is.EqualTo((613, "tt0363163"))); + } + + [TestCase("/media/Filme/Der Untergang (2004)/film.mkv")] + [TestCase("/media/Filme/Film [1080p]/film.mkv")] + [TestCase("")] + [TestCase("/media/Film [tmdbid-]/f.mkv")] + [TestCase("/media/Film [imdbid-nichts]/f.mkv")] + public void Ohne_Kennung_kommt_nichts_zurueck(string pfad) + { + Assert.That(ProviderIdParser.FromPath(pfad).Any, Is.False); + } +} + +public class NfoTests +{ + [Test] + public void Ein_Film_wird_geschrieben_und_wieder_gelesen() + { + var xml = Nfo.Write(new MovieNfo( + "Der Untergang", 2004, "Berlin, April 1945.", + new ProviderIds(613, "tt0363163"), Locked: true)); + + Assert.That(xml, Does.StartWith("Der Untergang")); + Assert.That(xml, Does.Contain("2004")); + + var gelesen = Nfo.ReadIds(xml); + Assert.That((gelesen.Tmdb, gelesen.Imdb), Is.EqualTo((613, "tt0363163"))); + Assert.That(Nfo.ReadLocked(xml), Is.True); + } + + /// Kennungen stehen doppelt drin, für alte wie neue Werkzeuge. + [Test] + public void Die_Kennungen_stehen_in_beiden_Formen_da() + { + var xml = Nfo.Write(new MovieNfo("Egal", null, null, new ProviderIds(613, "tt0363163"), false)); + + Assert.That(xml, Does.Contain("""613""")); + Assert.That(xml, Does.Contain("613")); + Assert.That(xml, Does.Contain("""tt0363163""")); + Assert.That(xml, Does.Contain("tt0363163")); + } + + [Test] + public void Die_aeltere_Form_allein_wird_auch_gelesen() + { + var gelesen = Nfo.ReadIds("613tt0363163"); + Assert.That((gelesen.Tmdb, gelesen.Imdb), Is.EqualTo((613, "tt0363163"))); + } + + [Test] + public void Sonderzeichen_im_Titel_werden_richtig_geschrieben() + { + var xml = Nfo.Write(new MovieNfo("Tom & Jerry ", null, null, ProviderIds.None, false)); + + Assert.That(xml, Does.Contain("Tom & Jerry <der Film>")); + Assert.That(Nfo.ReadIds(xml).Any, Is.False, "Und die Datei bleibt lesbar"); + } + + /// Eine von Hand geschriebene NFO ist oft kaputt — das darf nichts abbrechen. + [TestCase("")] + [TestCase("kein xml")] + [TestCase("offen")] + [TestCase("<movie><tmdbid>keine zahl</tmdbid></movie>")] + public void Unlesbares_ergibt_nichts_statt_eines_Absturzes(string kaputt) + { + Assert.That(Nfo.ReadIds(kaputt).Any, Is.False); + Assert.That(Nfo.ReadLocked(kaputt), Is.False); + } + + /// <summary>Eine NFO aus dem Medienordner hat Halo nicht geschrieben.</summary> + /// + /// <remarks> + /// <b>Ohne abgeschaltete DTDs wäre das ein Weg, fremde Dateien zu lesen.</b> + /// Eine untergeschobene NFO mit einer externen Entität könnte den Server + /// dazu bringen, <c>/config/halo.db</c> einzulesen und den Inhalt als + /// Titel auszuliefern. + /// </remarks> + [Test] + public void Eine_NFO_mit_externer_Entitaet_wird_nicht_ausgewertet() + { + var angriff = """ + <?xml version="1.0"?> + <!DOCTYPE movie [<!ENTITY xxe SYSTEM "file:///etc/passwd">]> + <movie><title>&xxe;613 + """; + + Assert.That(Nfo.ReadIds(angriff).Any, Is.False, "Die Datei wird gar nicht erst verarbeitet"); + Assert.That(Nfo.ReadLocked(angriff), Is.False); + } + + [Test] + public void Eine_Folge_bekommt_Staffel_und_Nummer() + { + var xml = Nfo.Write(new EpisodeNfo("Geheimnisse", 1, 1, "Ein Junge verschwindet.")); + + Assert.That(xml, Does.Contain("1")); + Assert.That(xml, Does.Contain("1")); + Assert.That(xml, Does.Contain("Geheimnisse")); + } +} diff --git a/server/tests/Halo.Media.Tests/SidecarWriterTests.cs b/server/tests/Halo.Media.Tests/SidecarWriterTests.cs new file mode 100644 index 0000000..79fce01 --- /dev/null +++ b/server/tests/Halo.Media.Tests/SidecarWriterTests.cs @@ -0,0 +1,146 @@ +using System.Text; +using Halo.Core.Library; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Halo.Media.Tests; + +/// Was wirklich auf der Platte landet. +public class SidecarWriterTests +{ + private string _ordner = ""; + private string _film = ""; + + [SetUp] + public void Anlegen() + { + _ordner = Directory.CreateDirectory(Path.Combine( + Path.GetTempPath(), "halo-beiwerk-" + Guid.CreateVersion7().ToString("N"), + "Der Untergang (2004)")).FullName; + _film = Path.Combine(_ordner, "Der Untergang (2004).mkv"); + File.WriteAllBytes(_film, new byte[2048]); + } + + [TearDown] + public void Aufraeumen() + { + try { Directory.Delete(Path.GetDirectoryName(_ordner)!, recursive: true); } catch (IOException) { } + } + + private static SidecarWriter Schreiber(bool an = true) => + new(new SidecarOptions(an), NullLogger.Instance); + + [Test] + public async Task Eine_NFO_landet_neben_dem_Film() + { + await Schreiber().WriteTextAsync(_film, SidecarNames.MovieNfo, "613"); + + var ziel = Path.Combine(_ordner, "movie.nfo"); + Assert.That(File.Exists(ziel), Is.True); + Assert.That(await File.ReadAllTextAsync(ziel), Does.Contain("613")); + } + + [Test] + public async Task Ein_Bild_landet_neben_dem_Film() + { + using var inhalt = new MemoryStream(Encoding.UTF8.GetBytes("ein plakat")); + await Schreiber().WriteStreamAsync(_film, SidecarNames.MoviePoster, inhalt); + + Assert.That(File.Exists(Path.Combine(_ordner, "poster.jpg")), Is.True); + } + + /// Die Prüfung, an der der Schutz des Bestands hängt. + /// + /// + /// Es muss laut scheitern, nicht still übergehen. Käme hier ein + /// Name durch, der nicht auf der Liste steht, wäre der gesamte Bestand + /// offen — und ein stilles Übergehen verstecke den Fehler, bis jemand + /// seine Filme vermisst. + /// + [TestCase("film.mkv")] + [TestCase("../anderswo.nfo")] + [TestCase("/etc/passwd")] + [TestCase("unterordner/poster.jpg")] + [TestCase("beliebig.txt")] + public void Was_nicht_auf_der_Liste_steht_wird_laut_abgelehnt(string versuch) + { + var fehler = Assert.ThrowsAsync( + () => Schreiber().WriteTextAsync(_film, versuch, "egal")); + + Assert.That(fehler!.Message, Does.Contain("Liste")); + } + + /// Die Mediendatei bleibt unberührt — auch beim Versuch. + [Test] + public void Die_Mediendatei_wird_nie_angefasst() + { + var vorher = new FileInfo(_film).Length; + + Assert.ThrowsAsync( + () => Schreiber().WriteTextAsync(_film, "Der Untergang (2004).mkv", "kaputt")); + + Assert.That(new FileInfo(_film).Length, Is.EqualTo(vorher)); + Assert.That(File.ReadAllBytes(_film).All(b => b == 0), Is.True); + } + + /// Abgeschaltet heisst abgeschaltet. + [Test] + public async Task Ohne_Erlaubnis_wird_nichts_geschrieben() + { + await Schreiber(an: false).WriteTextAsync(_film, SidecarNames.MovieNfo, ""); + + Assert.That(File.Exists(Path.Combine(_ordner, "movie.nfo")), Is.False); + Assert.That(Schreiber(an: false).Enabled, Is.False); + } + + /// Gelesen wird trotzdem. + /// + /// + /// Eine vorhandene NFO ist eine Zuordnung, die jemand getroffen hat. Sie + /// zu übergehen, bloss weil Halo selbst nicht schreiben darf, wäre + /// verkehrt herum. + /// + [Test] + public async Task Gelesen_wird_auch_ohne_Schreiberlaubnis() + { + await File.WriteAllTextAsync(Path.Combine(_ordner, "movie.nfo"), "613"); + + var gelesen = await Schreiber(an: false).ReadTextAsync(_film, SidecarNames.MovieNfo); + Assert.That(Nfo.ReadIds(gelesen!).Tmdb, Is.EqualTo(613)); + } + + [Test] + public async Task Eine_fehlende_Datei_ergibt_null() + { + Assert.That(await Schreiber().ReadTextAsync(_film, SidecarNames.MovieNfo), Is.Null); + } + + [Test] + public async Task Ein_zweites_Schreiben_ersetzt_das_erste() + { + await Schreiber().WriteTextAsync(_film, SidecarNames.MovieNfo, "alt"); + await Schreiber().WriteTextAsync(_film, SidecarNames.MovieNfo, "neu"); + + Assert.That(await File.ReadAllTextAsync(Path.Combine(_ordner, "movie.nfo")), Is.EqualTo("neu")); + Assert.That(Directory.GetFiles(_ordner, "*.halo-tmp"), Is.Empty, "Keine Reste vom Umbenennen"); + } + + [Test] + public void Ein_Film_allein_im_Ordner_wird_erkannt() + { + Assert.That(Schreiber().IsAloneInFolder(_film), Is.True); + + File.WriteAllBytes(Path.Combine(_ordner, "Noch ein Film (2005).mkv"), new byte[10]); + Assert.That(Schreiber().IsAloneInFolder(_film), Is.False); + } + + /// Beiwerk zählt nicht als zweiter Film. + [Test] + public async Task Das_eigene_Beiwerk_macht_den_Film_nicht_unselbstaendig() + { + await Schreiber().WriteTextAsync(_film, SidecarNames.MovieNfo, ""); + using var bild = new MemoryStream([1, 2, 3]); + await Schreiber().WriteStreamAsync(_film, SidecarNames.MoviePoster, bild); + + Assert.That(Schreiber().IsAloneInFolder(_film), Is.True); + } +} diff --git a/server/tests/Halo.Metadata.Tests/TmdbClientTests.cs b/server/tests/Halo.Metadata.Tests/TmdbClientTests.cs index 573fe5f..f7bf623 100644 --- a/server/tests/Halo.Metadata.Tests/TmdbClientTests.cs +++ b/server/tests/Halo.Metadata.Tests/TmdbClientTests.cs @@ -48,7 +48,9 @@ public class TmdbClientTests var attrappe = Attrappe.MitJson(FilmTreffer); await Client(attrappe, FesteEinstellungen.MitSchluessel("de-DE")).FindMovieAsync("Der Untergang", 2004); - var aufruf = attrappe.Aufrufe.Single(); + // Die erste Anfrage ist die Suche; danach folgt der Nachschlag der + // IMDb-Kennung für die NFO-Datei. + var aufruf = attrappe.Aufrufe[0]; Assert.That(aufruf, Does.Contain("query=Der%20Untergang")); Assert.That(aufruf, Does.Contain("year=2004")); Assert.That(aufruf, Does.Contain("language=de-DE")); @@ -59,7 +61,7 @@ public class TmdbClientTests { var attrappe = Attrappe.MitJson(FilmTreffer); await Client(attrappe).FindMovieAsync("Matrix", null); - Assert.That(attrappe.Aufrufe.Single(), Does.Not.Contain("year=")); + Assert.That(attrappe.Aufrufe[0], Does.Not.Contain("year=")); } [Test] diff --git a/server/tests/Halo.Metadata.Tests/ZugangTests.cs b/server/tests/Halo.Metadata.Tests/ZugangTests.cs index 25437f8..2815f7c 100644 --- a/server/tests/Halo.Metadata.Tests/ZugangTests.cs +++ b/server/tests/Halo.Metadata.Tests/ZugangTests.cs @@ -31,9 +31,11 @@ public class ZugangTests var (client, attrappe) = Mit(LeseToken); await client.FindMovieAsync("Egal", null); - Assert.That(attrappe.Kopfzeilen.Single(), Is.EqualTo($"Bearer {LeseToken}")); - Assert.That(attrappe.Aufrufe.Single(), Does.Not.Contain("api_key")); - Assert.That(attrappe.Aufrufe.Single(), Does.Not.Contain(LeseToken)); + // **Jede** Anfrage, nicht nur die erste: seit der IMDb-Nachschlag + // dazukam, gehen zwei hinaus, und beide müssen es richtig machen. + Assert.That(attrappe.Kopfzeilen, Is.All.EqualTo($"Bearer {LeseToken}")); + Assert.That(attrappe.Aufrufe, Is.All.Not.Contains("api_key")); + Assert.That(attrappe.Aufrufe, Is.All.Not.Contains(LeseToken)); } /// Der ältere Schlüssel kann nur in die Adresse — also tut er das. @@ -43,8 +45,8 @@ public class ZugangTests var (client, attrappe) = Mit(AelterSchluessel); await client.FindMovieAsync("Egal", null); - Assert.That(attrappe.Kopfzeilen.Single(), Is.Null); - Assert.That(attrappe.Aufrufe.Single(), Does.Contain($"api_key={AelterSchluessel}")); + Assert.That(attrappe.Kopfzeilen, Is.All.Null); + Assert.That(attrappe.Aufrufe, Is.All.Contains($"api_key={AelterSchluessel}")); } [Test] @@ -53,8 +55,8 @@ public class ZugangTests var (client, attrappe) = Mit(LeseToken); await client.FindEpisodeAsync(70523, 1, 1); - Assert.That(attrappe.Kopfzeilen.Single(), Does.StartWith("Bearer ")); - Assert.That(attrappe.Aufrufe.Single(), Does.Not.Contain("api_key")); + Assert.That(attrappe.Kopfzeilen, Is.All.StartsWith("Bearer ")); + Assert.That(attrappe.Aufrufe, Is.All.Not.Contains("api_key")); } }