Beiwerk im Medienordner, und eine Zuordnung lässt sich korrigieren

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 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-03 19:01:26 +02:00
co-authored by Claude Opus 5
parent 79b6001f86
commit f98b995050
30 changed files with 2911 additions and 39 deletions
+57 -1
View File
@@ -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.
+25 -2
View File
@@ -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
@@ -120,3 +120,15 @@ public sealed record DeliveryInfo(
string PackageState,
double Progress,
string? Error);
// --- Zuordnung korrigieren -----------------------------------------------
/// <summary>Ein Vorschlag, aus dem gewählt werden kann.</summary>
public sealed record MatchCandidate(
int TmdbId, string Title, int? Year, string? Overview, string? PosterUrl);
public sealed record ApplyMatchRequest(int TmdbId);
/// <summary>Wie die Zuordnung jetzt aussieht.</summary>
public sealed record MatchState(
int? TmdbId, string? ImdbId, bool Locked, string Title, int? Year);
@@ -0,0 +1,88 @@
using Halo.Core.Library;
using Halo.Core.Metadata;
using Halo.Data;
using Microsoft.EntityFrameworkCore;
namespace Halo.Api.Endpoints;
/// <summary>Eine falsche Zuordnung von Hand richtigstellen.</summary>
///
/// <remarks>
/// <para>
/// <b>Weil Raten manchmal danebengeht.</b> 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.
/// </para>
/// <para>
/// <b>Eine Korrektur wird festgeschrieben.</b> In der Datenbank <i>und</i> in
/// der NFO-Datei neben dem Film, mit <c>lockdata</c>. Danach sucht kein Lauf
/// mehr danach — und ein Neuaufbau der Datenbank oder ein Umzug auf einen
/// anderen Server findet die Entscheidung wieder.
/// </para>
/// </remarks>
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);
}
}
+127
View File
@@ -0,0 +1,127 @@
using Halo.Core.Library;
using Halo.Core.Metadata;
using Halo.Data;
using Microsoft.EntityFrameworkCore;
namespace Halo.Api;
/// <summary>Schreibt eine korrigierte Zuordnung überallhin, wo sie hingehört.</summary>
///
/// <remarks>
/// <b>An drei Stellen, und alle drei sind nötig.</b> 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.
/// </remarks>
public sealed class MatchApplier(
HaloContext db,
IMetadataProvider anbieter,
IImageStore bilder,
IImageReader bildLeser,
ISidecarStore beiwerk,
ILogger<MatchApplier> 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<Episode> 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<string?> 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);
}
+8
View File
@@ -67,6 +67,11 @@ builder.Services.AddSingleton(FfmpegOptions.FromEnvironment());
builder.Services.AddSingleton<IMediaProbe, FfprobeRunner>();
builder.Services.AddSingleton<FfmpegRemuxer>();
// 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<ISidecarStore, SidecarWriter>();
builder.Services.AddScoped<LibraryScanner>();
// Metadaten. Der Anbieter steckt hinter einer Schnittstelle, damit alles, was
@@ -82,7 +87,9 @@ builder.Services.AddScoped<IMetadataProvider>(sp => sp.GetRequiredService<TmdbCl
builder.Services.AddSingleton(new ImageCacheOptions(pfade.Cache));
builder.Services.AddHttpClient<ImageCache>(c => c.Timeout = TimeSpan.FromSeconds(30));
builder.Services.AddScoped<IImageStore>(sp => sp.GetRequiredService<ImageCache>());
builder.Services.AddScoped<IImageReader>(sp => sp.GetRequiredService<ImageCache>());
builder.Services.AddScoped<MetadataEnricher>();
builder.Services.AddScoped<MatchApplier>();
builder.Services.AddSingleton<ScanCoordinator>();
builder.Services.AddSingleton<PackageCoordinator>();
builder.Services.AddProblemDetails();
@@ -109,6 +116,7 @@ app.MapLibraryContent();
app.MapSettings();
app.MapImages();
app.MapDelivery();
app.MapMatching();
app.Run();
@@ -0,0 +1,29 @@
namespace Halo.Core.Library;
/// <summary>Legt Beiwerk neben eine Mediendatei.</summary>
///
/// <remarks>
/// <b>Jede Umsetzung muss <see cref="SidecarNames.MayWrite"/> befragen.</b>
/// Die Erlaubnisliste ist der einzige Schutz des Bestands, seit die
/// Einhängung schreibbar ist — eine Umsetzung, die sie übergeht, hebelt ihn
/// aus.
/// </remarks>
public interface ISidecarStore
{
/// <summary>Ob überhaupt geschrieben werden soll und darf.</summary>
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<string?> ReadTextAsync(string mediendatei, string beiwerkName, CancellationToken ct = default);
/// <summary>Ob diese Datei allein in ihrem Ordner liegt.</summary>
///
/// <remarks>
/// Entscheidet, ob das Beiwerk schlicht <c>poster.jpg</c> heisst oder den
/// Dateinamen davor bekommt.
/// </remarks>
bool IsAloneInFolder(string mediendatei);
}
@@ -70,6 +70,18 @@ public sealed class Movie
/// </remarks>
public required string ParsedTitle { get; init; }
/// <summary>Das Jahr, wie es im Dateinamen stand.</summary>
///
/// <remarks>
/// <b>Aus demselben Grund wie <see cref="ParsedTitle"/>, und er wurde
/// teuer gelernt.</b> 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.
/// </remarks>
public int? ParsedYear { get; init; }
public required string Title { get; set; }
/// <summary>Ohne führenden Artikel — siehe <see cref="SortTitle"/>.</summary>
@@ -81,6 +93,23 @@ public sealed class Movie
/// <summary>Die Kennung bei TMDB, sobald zugeordnet.</summary>
public int? TmdbId { get; set; }
/// <summary>Die Kennung bei IMDb — für die NFO-Datei.</summary>
///
/// <remarks>
/// Damit ein anderes Werkzeug denselben Titel wiederfindet, auch wenn es
/// TMDB gar nicht kennt.
/// </remarks>
public string? ImdbId { get; set; }
/// <summary>Ob jemand diese Zuordnung von Hand festgelegt hat.</summary>
///
/// <remarks>
/// Dann sucht kein Lauf mehr danach. Steht auch als <c>lockdata</c> in der
/// NFO-Datei — so überlebt die Entscheidung einen Neuaufbau der Datenbank
/// und einen Umzug auf einen anderen Server.
/// </remarks>
public bool MatchLocked { get; set; }
/// <summary>Schlüssel des zwischengespeicherten Plakats — siehe Bildablage.</summary>
///
/// <remarks>
@@ -110,11 +139,40 @@ public sealed class Series
/// </remarks>
public required string ParsedTitle { get; init; }
/// <summary>Das Jahr, wie es im Dateinamen stand.</summary>
///
/// <remarks>
/// <b>Aus demselben Grund wie <see cref="ParsedTitle"/>, und er wurde
/// teuer gelernt.</b> 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.
/// </remarks>
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; }
/// <summary>Die Kennung bei IMDb — für die NFO-Datei.</summary>
///
/// <remarks>
/// Damit ein anderes Werkzeug denselben Titel wiederfindet, auch wenn es
/// TMDB gar nicht kennt.
/// </remarks>
public string? ImdbId { get; set; }
/// <summary>Ob jemand diese Zuordnung von Hand festgelegt hat.</summary>
///
/// <remarks>
/// Dann sucht kein Lauf mehr danach. Steht auch als <c>lockdata</c> in der
/// NFO-Datei — so überlebt die Entscheidung einen Neuaufbau der Datenbank
/// und einen Umzug auf einen anderen Server.
/// </remarks>
public bool MatchLocked { get; set; }
/// <summary>Schlüssel des zwischengespeicherten Plakats — siehe Bildablage.</summary>
///
/// <remarks>
+154
View File
@@ -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);
/// <summary>Die NFO-Datei neben dem Film: schreiben und lesen.</summary>
///
/// <remarks>
/// <para>
/// <b>Die Form stammt von Kodi und wird von Jellyfin, Plex und Emby
/// gelesen.</b> 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.
/// </para>
/// <para>
/// <b>Kennungen stehen doppelt drin.</b> Einmal als <c>&lt;uniqueid&gt;</c> —
/// die neuere Form — und einmal als <c>&lt;tmdbid&gt;</c> und
/// <c>&lt;imdbid&gt;</c>, weil ältere Werkzeuge nur die kennen. Zwei Zeilen
/// mehr gegen die Frage, warum ein anderes Programm nichts findet.
/// </para>
/// </remarks>
public static class Nfo
{
/// <summary>Verhindert, dass ein Scan eine Korrektur von Hand überschreibt.</summary>
///
/// <remarks>
/// Kodi und Jellyfin kennen dieses Feld unter demselben Namen. Steht es
/// auf <c>true</c>, hat jemand die Zuordnung ausdrücklich festgelegt —
/// und dann hat kein automatischer Lauf daran etwas zu ändern.
/// </remarks>
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));
});
/// <summary>Liest Kennungen aus einer vorhandenen NFO.</summary>
///
/// <remarks>
/// Gibt bei unlesbarem Inhalt <see cref="ProviderIds.None"/> zurück statt
/// zu werfen: eine von Hand geschriebene Datei ist oft kaputt, und das
/// darf keinen Scan abbrechen.
/// </remarks>
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);
}
/// <summary>Ob diese Zuordnung von Hand festgelegt wurde.</summary>
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<XElement> 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, "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n");
}
/// <summary>Liest XML, ohne sich dabei etwas unterschieben zu lassen.</summary>
///
/// <remarks>
/// <b>DTDs sind abgeschaltet und es wird nichts nachgeladen.</b> 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.
/// </remarks>
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; }
}
}
@@ -0,0 +1,47 @@
using System.Text.RegularExpressions;
namespace Halo.Core.Library;
/// <summary>Kennungen bei Metadatenquellen, wie sie in Namen stehen können.</summary>
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;
}
/// <summary>Liest Kennungen aus Ordner- und Dateinamen.</summary>
///
/// <remarks>
/// <para>
/// <b>Die Form stammt von Kodi und wird von Jellyfin gelesen:</b>
/// <c>Der Untergang (2004) [tmdbid-613]</c>. Wer seine Ordner so benennt —
/// von Hand oder über Radarr —, hat seine Zuordnung damit festgeschrieben,
/// und Halo soll sie nicht erneut erraten.
/// </para>
/// <para>
/// <b>Halo benennt nichts um.</b> Es liest nur. Festgeschrieben wird eine
/// Korrektur in der NFO-Datei — dieselbe Wirkung, ohne den einen Eingriff,
/// der eine Bibliothek wirklich zerlegen kann.
/// </para>
/// </remarks>
public static partial class ProviderIdParser
{
[GeneratedRegex(@"\[tmdbid-(?<id>\d{1,9})\]", RegexOptions.IgnoreCase)]
private static partial Regex TmdbKlammer();
[GeneratedRegex(@"\[imdbid-(?<id>tt\d{6,10})\]", RegexOptions.IgnoreCase)]
private static partial Regex ImdbKlammer();
/// <summary>Sucht im ganzen Pfad — der Ordner trägt sie meist, nicht die Datei.</summary>
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);
}
}
@@ -0,0 +1,89 @@
namespace Halo.Core.Library;
/// <summary>Welche Dateien Halo neben eine Mediendatei legen darf — und sonst keine.</summary>
///
/// <remarks>
/// <para>
/// <b>Eine Erlaubnisliste, und sie ist der ganze Schutz.</b> 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.
/// </para>
/// <para>
/// <b>Mediendateien werden nie angefasst.</b> 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.
/// </para>
/// <para>
/// <b>Transcode-Zwischenstände gehören ausdrücklich nicht hierher.</b> Die
/// liegen unter <c>HALO_TRANSCODE</c>, auf einem eigenen Volume. Ein
/// Medienordner ist kein Kratzplatz.
/// </para>
/// </remarks>
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";
/// <summary>Was zu einer Folge gehört, hängt am Namen ihrer Datei.</summary>
///
/// <remarks>
/// Aus <c>Dark - S01E01.mkv</c> werden <c>Dark - S01E01.nfo</c> und
/// <c>Dark - S01E01-thumb.jpg</c>. So erwarten es Kodi, Jellyfin und Plex
/// — und so findet auch ein Mensch sie wieder.
/// </remarks>
public static string EpisodeNfo(string mediendatei) =>
Path.GetFileNameWithoutExtension(mediendatei) + ".nfo";
public static string EpisodeThumb(string mediendatei) =>
Path.GetFileNameWithoutExtension(mediendatei) + "-thumb.jpg";
/// <summary>Beiwerk für einen Film, der sich den Ordner mit anderen teilt.</summary>
///
/// <remarks>
/// <b>Sonst überschreiben sich zwei Filme gegenseitig das Plakat.</b> 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.
/// </remarks>
public static string Prefixed(string mediendatei, string beiwerk) =>
Path.GetFileNameWithoutExtension(mediendatei) + "-" + beiwerk;
private static readonly HashSet<string> Feste = new(StringComparer.OrdinalIgnoreCase)
{ MoviePoster, MovieBackdrop, MovieLogo, MovieNfo, SeriesNfo };
private static readonly string[] Endungen =
[".nfo", "-thumb.jpg", "-poster.jpg", "-fanart.jpg", "-logo.png"];
/// <summary>Ob dieser Dateiname geschrieben werden darf.</summary>
///
/// <remarks>
/// <b>Geprüft wird der Name, nicht die Absicht.</b> Ein Pfadbestandteil,
/// ein Verzeichniswechsel oder ein führender Punkt kommen hier nicht
/// durch — und „film.mkv" erst recht nicht, auch wenn es auf <c>.nfo</c>
/// endete.
/// </remarks>
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);
}
}
@@ -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<SeriesMetadata?> FindSeriesAsync(string title, int? year, CancellationToken ct = default);
Task<EpisodeMetadata?> FindEpisodeAsync(int seriesProviderId, int season, int episode, CancellationToken ct = default);
/// <summary>Holt einen Titel über seine Kennung, ohne zu suchen.</summary>
///
/// <remarks>
/// <b>Für alles, was bereits zugeordnet ist.</b> 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.
/// </remarks>
Task<MovieMetadata?> GetMovieAsync(int providerId, CancellationToken ct = default);
Task<SeriesMetadata?> GetSeriesAsync(int providerId, CancellationToken ct = default);
/// <summary>Mehrere Vorschläge zur Auswahl — für das Korrigieren von Hand.</summary>
Task<IReadOnlyList<MovieMetadata>> SearchMoviesAsync(string title, int? year, CancellationToken ct = default);
Task<IReadOnlyList<SeriesMetadata>> SearchSeriesAsync(string title, int? year, CancellationToken ct = default);
}
/// <summary>Der Schlüssel stimmt nicht — das muss der Betreiber erfahren.</summary>
@@ -67,3 +84,14 @@ public interface IImageStore
/// <summary>Holt das Bild und gibt seinen Schlüssel zurück — oder <c>null</c>.</summary>
Task<string?> StoreAsync(RemoteImage bild, CancellationToken ct = default);
}
/// <summary>Ein Bild aus der Ablage wieder herausgeben.</summary>
///
/// <remarks>
/// Gebraucht, um ein zwischengespeichertes Plakat zusätzlich in den
/// Medienordner zu legen — ohne es ein zweites Mal herunterzuladen.
/// </remarks>
public interface IImageReader
{
Task<Stream?> OpenAsync(string schlüssel, CancellationToken ct = default);
}
+2
View File
@@ -121,6 +121,7 @@ public sealed class HaloContext(DbContextOptions<HaloContext> 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<Core.Libraries.Library>().WithMany().HasForeignKey(x => x.LibraryId)
.OnDelete(DeleteBehavior.Cascade);
});
@@ -131,6 +132,7 @@ public sealed class HaloContext(DbContextOptions<HaloContext> 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<Core.Libraries.Library>().WithMany().HasForeignKey(x => x.LibraryId)
.OnDelete(DeleteBehavior.Cascade);
});
+3 -1
View File
@@ -230,13 +230,15 @@ public sealed class LibraryScanner(
private Movie FilmFinden(List<Movie> 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,
+159 -6
View File
@@ -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<MetadataEnricher> log)
{
public async Task<EnrichReport> 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;
}
/// <summary>Liest eine Zuordnung, die schon dasteht.</summary>
///
/// <remarks>
/// Zwei Quellen, in dieser Reihenfolge: die NFO-Datei neben dem Film und
/// der Ordnername in der Form <c>[tmdbid-613]</c>. Die NFO gewinnt — sie
/// ist die ausdrücklichere Aussage.
/// </remarks>
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<int> 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;
}
/// <summary>Für Serien liegt die tvshow.nfo einen Ordner höher.</summary>
///
/// <remarks>
/// Die Folgen stecken in Staffelordnern; die Serienangaben gehören
/// daneben, nicht hinein. Deshalb wird von der Folgendatei aus eine Ebene
/// nach oben gegangen.
/// </remarks>
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);
}
/// <summary>Der Ordner der Folge und der darüber — mehr nicht.</summary>
private static IEnumerable<string> 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");
}
/// <summary>Folgen nachschlagen — aber nur bei Serien, die zugeordnet sind.</summary>
private async Task<int> 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<string?> 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);
}
@@ -0,0 +1,498 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid?>("ApprovedByUserId")
.HasColumnType("TEXT");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<long?>("ConsumedAt")
.HasColumnType("INTEGER");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("DeviceName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("ExpiresAt")
.HasColumnType("INTEGER");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("DeviceName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("LastSeenAt")
.HasColumnType("INTEGER");
b.Property<long?>("RevokedAt")
.HasColumnType("INTEGER");
b.Property<string>("TokenFingerprint")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Libraries");
});
modelBuilder.Entity("Halo.Core.Library.Episode", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<int>("EpisodeNumber")
.HasColumnType("INTEGER");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<int>("SeasonNumber")
.HasColumnType("INTEGER");
b.Property<Guid>("SeriesId")
.HasColumnType("TEXT");
b.Property<string>("StillImage")
.HasColumnType("TEXT");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long?>("BitRate")
.HasColumnType("INTEGER");
b.Property<string>("Container")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<long?>("Duration")
.HasColumnType("INTEGER");
b.Property<Guid?>("EpisodeId")
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<long>("ModifiedAt")
.HasColumnType("INTEGER");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<long?>("ProbedAt")
.HasColumnType("INTEGER");
b.Property<long>("SeenAt")
.HasColumnType("INTEGER");
b.Property<long>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<int?>("Channels")
.HasColumnType("INTEGER");
b.Property<string>("Codec")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int?>("Height")
.HasColumnType("INTEGER");
b.Property<int>("Index")
.HasColumnType("INTEGER");
b.Property<bool>("IsDefault")
.HasColumnType("INTEGER");
b.Property<bool>("IsForced")
.HasColumnType("INTEGER");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("Language")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<Guid>("MediaFileId")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int?>("Width")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("MediaFileId", "Index")
.IsUnique();
b.ToTable("MediaStreams");
});
modelBuilder.Entity("Halo.Core.Library.Movie", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<string>("BackdropImage")
.HasColumnType("TEXT");
b.Property<string>("ImdbId")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<bool>("MatchLocked")
.HasColumnType("INTEGER");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<string>("ParsedTitle")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PosterImage")
.HasColumnType("TEXT");
b.Property<string>("SortTitleValue")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<int?>("TmdbId")
.HasColumnType("INTEGER");
b.Property<int?>("Year")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("LibraryId", "SortTitleValue");
b.ToTable("Movies");
});
modelBuilder.Entity("Halo.Core.Library.Series", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<string>("BackdropImage")
.HasColumnType("TEXT");
b.Property<string>("ImdbId")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<bool>("MatchLocked")
.HasColumnType("INTEGER");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<string>("ParsedTitle")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PosterImage")
.HasColumnType("TEXT");
b.Property<string>("SortTitleValue")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<int?>("TmdbId")
.HasColumnType("INTEGER");
b.Property<int?>("Year")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("LibraryId", "SortTitleValue");
b.ToTable("Series");
});
modelBuilder.Entity("Halo.Core.ServerSetting", b =>
{
b.Property<string>("Key")
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("TEXT");
b.HasKey("Key");
b.ToTable("Settings");
});
modelBuilder.Entity("Halo.Core.Users.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<bool>("IsAdministrator")
.HasColumnType("INTEGER");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("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
}
}
}
@@ -0,0 +1,62 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Halo.Data.Migrations
{
/// <inheritdoc />
public partial class Zuordnung : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ImdbId",
table: "Series",
type: "TEXT",
maxLength: 32,
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "MatchLocked",
table: "Series",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<string>(
name: "ImdbId",
table: "Movies",
type: "TEXT",
maxLength: 32,
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "MatchLocked",
table: "Movies",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
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");
}
}
}
@@ -0,0 +1,504 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid?>("ApprovedByUserId")
.HasColumnType("TEXT");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<long?>("ConsumedAt")
.HasColumnType("INTEGER");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("DeviceName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("ExpiresAt")
.HasColumnType("INTEGER");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("DeviceName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("LastSeenAt")
.HasColumnType("INTEGER");
b.Property<long?>("RevokedAt")
.HasColumnType("INTEGER");
b.Property<string>("TokenFingerprint")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Libraries");
});
modelBuilder.Entity("Halo.Core.Library.Episode", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<int>("EpisodeNumber")
.HasColumnType("INTEGER");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<int>("SeasonNumber")
.HasColumnType("INTEGER");
b.Property<Guid>("SeriesId")
.HasColumnType("TEXT");
b.Property<string>("StillImage")
.HasColumnType("TEXT");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long?>("BitRate")
.HasColumnType("INTEGER");
b.Property<string>("Container")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<long?>("Duration")
.HasColumnType("INTEGER");
b.Property<Guid?>("EpisodeId")
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<long>("ModifiedAt")
.HasColumnType("INTEGER");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<long?>("ProbedAt")
.HasColumnType("INTEGER");
b.Property<long>("SeenAt")
.HasColumnType("INTEGER");
b.Property<long>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<int?>("Channels")
.HasColumnType("INTEGER");
b.Property<string>("Codec")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int?>("Height")
.HasColumnType("INTEGER");
b.Property<int>("Index")
.HasColumnType("INTEGER");
b.Property<bool>("IsDefault")
.HasColumnType("INTEGER");
b.Property<bool>("IsForced")
.HasColumnType("INTEGER");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("Language")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<Guid>("MediaFileId")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int?>("Width")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("MediaFileId", "Index")
.IsUnique();
b.ToTable("MediaStreams");
});
modelBuilder.Entity("Halo.Core.Library.Movie", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<string>("BackdropImage")
.HasColumnType("TEXT");
b.Property<string>("ImdbId")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<bool>("MatchLocked")
.HasColumnType("INTEGER");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<string>("ParsedTitle")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("ParsedYear")
.HasColumnType("INTEGER");
b.Property<string>("PosterImage")
.HasColumnType("TEXT");
b.Property<string>("SortTitleValue")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<int?>("TmdbId")
.HasColumnType("INTEGER");
b.Property<int?>("Year")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("LibraryId", "SortTitleValue");
b.ToTable("Movies");
});
modelBuilder.Entity("Halo.Core.Library.Series", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<string>("BackdropImage")
.HasColumnType("TEXT");
b.Property<string>("ImdbId")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<bool>("MatchLocked")
.HasColumnType("INTEGER");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<string>("ParsedTitle")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("ParsedYear")
.HasColumnType("INTEGER");
b.Property<string>("PosterImage")
.HasColumnType("TEXT");
b.Property<string>("SortTitleValue")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<int?>("TmdbId")
.HasColumnType("INTEGER");
b.Property<int?>("Year")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("LibraryId", "SortTitleValue");
b.ToTable("Series");
});
modelBuilder.Entity("Halo.Core.ServerSetting", b =>
{
b.Property<string>("Key")
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("TEXT");
b.HasKey("Key");
b.ToTable("Settings");
});
modelBuilder.Entity("Halo.Core.Users.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<bool>("IsAdministrator")
.HasColumnType("INTEGER");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("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
}
}
}
@@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Halo.Data.Migrations
{
/// <inheritdoc />
public partial class GeparstesJahr : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "ParsedYear",
table: "Series",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "ParsedYear",
table: "Movies",
type: "INTEGER",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ParsedYear",
table: "Series");
migrationBuilder.DropColumn(
name: "ParsedYear",
table: "Movies");
}
}
}
@@ -291,9 +291,16 @@ namespace Halo.Data.Migrations
b.Property<string>("BackdropImage")
.HasColumnType("TEXT");
b.Property<string>("ImdbId")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<bool>("MatchLocked")
.HasColumnType("INTEGER");
b.Property<string>("Overview")
.HasColumnType("TEXT");
@@ -301,6 +308,9 @@ namespace Halo.Data.Migrations
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("ParsedYear")
.HasColumnType("INTEGER");
b.Property<string>("PosterImage")
.HasColumnType("TEXT");
@@ -339,9 +349,16 @@ namespace Halo.Data.Migrations
b.Property<string>("BackdropImage")
.HasColumnType("TEXT");
b.Property<string>("ImdbId")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<bool>("MatchLocked")
.HasColumnType("INTEGER");
b.Property<string>("Overview")
.HasColumnType("TEXT");
@@ -349,6 +366,9 @@ namespace Halo.Data.Migrations
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("ParsedYear")
.HasColumnType("INTEGER");
b.Property<string>("PosterImage")
.HasColumnType("TEXT");
+123
View File
@@ -0,0 +1,123 @@
using Halo.Core.Library;
using Microsoft.Extensions.Logging;
namespace Halo.Media;
/// <summary>Ob Halo überhaupt in Medienordner schreiben darf.</summary>
///
/// <remarks>
/// <b>Standardmässig aus.</b> 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
/// <c>HALO_WRITE_SIDECARS=true</c> und hängt die Medien ohne <c>:ro</c> ein.
/// </remarks>
public sealed record SidecarOptions(bool Enabled)
{
public static SidecarOptions FromEnvironment() => new(
Environment.GetEnvironmentVariable("HALO_WRITE_SIDECARS")?.Trim()
.Equals("true", StringComparison.OrdinalIgnoreCase) == true);
}
/// <summary>Legt Plakate und NFO-Dateien neben die Mediendatei.</summary>
///
/// <remarks>
/// <para>
/// <b>Jeder Schreibvorgang geht durch <see cref="SidecarNames.MayWrite"/>.</b>
/// 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.
/// </para>
/// <para>
/// <b>Geschrieben wird daneben und dann umbenannt.</b> Bricht der Vorgang ab,
/// liegt sonst ein halbes Plakat unter dem richtigen Namen — und wird nie
/// wieder erneuert, weil die Datei ja da ist.
/// </para>
/// </remarks>
public sealed class SidecarWriter(SidecarOptions optionen, ILogger<SidecarWriter> 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<string?> 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<string, Task> 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.");
}
}
+20 -1
View File
@@ -26,7 +26,7 @@ public sealed record ImageCacheOptions(string Root);
public sealed class ImageCache(
HttpClient http,
ImageCacheOptions optionen,
ILogger<ImageCache> log) : IImageStore
ILogger<ImageCache> log) : IImageStore, IImageReader
{
/// <summary>Mehr als das ist kein Plakat, sondern ein Versehen.</summary>
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");
}
/// <summary>Gibt ein abgelegtes Bild zum Lesen heraus.</summary>
///
/// <remarks>
/// Damit dasselbe Plakat zusätzlich in den Medienordner gelegt werden
/// kann, ohne es ein zweites Mal herunterzuladen.
/// </remarks>
public Task<Stream?> OpenAsync(string schlüssel, CancellationToken ct = default)
{
var pfad = PfadFür(schlüssel);
if (pfad is null || !File.Exists(pfad)) return Task.FromResult<Stream?>(null);
try { return Task.FromResult<Stream?>(File.OpenRead(pfad)); }
catch (IOException fehler)
{
log.LogDebug(fehler, "{Pfad} liess sich nicht öffnen.", pfad);
return Task.FromResult<Stream?>(null);
}
}
private static string Schlüssel(string adresse) =>
Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(adresse)));
}
+89 -17
View File
@@ -58,30 +58,84 @@ public sealed class TmdbClient(
{
var treffer = await SuchenAsync<TmdbMovie>("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<IReadOnlyList<MovieMetadata>> SearchMoviesAsync(
string title, int? year, CancellationToken ct = default)
{
var alle = await AlleAsync<TmdbMovie>("search/movie", title,
year is { } j ? ("year", j.ToString()) : null, ct);
return alle.Select(m => Als(m, title)).ToList();
}
public async Task<MovieMetadata?> GetMovieAsync(int providerId, CancellationToken ct = default)
{
var einstellung = await einstellungen.GetAsync(ct);
if (string.IsNullOrWhiteSpace(einstellung.ApiKey)) return null;
var film = await HolenAsync<TmdbMovie>(
$"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<SeriesMetadata?> FindSeriesAsync(string title, int? year, CancellationToken ct = default)
{
var treffer = await SuchenAsync<TmdbSeries>("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<IReadOnlyList<SeriesMetadata>> SearchSeriesAsync(
string title, int? year, CancellationToken ct = default)
{
var alle = await AlleAsync<TmdbSeries>("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<SeriesMetadata?> GetSeriesAsync(int providerId, CancellationToken ct = default)
{
var einstellung = await einstellungen.GetAsync(ct);
if (string.IsNullOrWhiteSpace(einstellung.ApiKey)) return null;
var serie = await HolenAsync<TmdbSeries>(
$"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));
/// <summary>Holt die IMDb-Kennung nach — eine Anfrage je neuem Treffer.</summary>
///
/// <remarks>
/// <b>Die Suche bei TMDB liefert sie nicht mit.</b> 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.
/// </remarks>
private async Task<MovieMetadata> MitImdb(MovieMetadata film, string art, CancellationToken ct) =>
film with { ImdbId = await ImdbAsync(art, film.ProviderId, ct) };
private async Task<SeriesMetadata> MitImdb(SeriesMetadata serie, string art, CancellationToken ct) =>
serie with { ImdbId = await ImdbAsync(art, serie.ProviderId, ct) };
private async Task<string?> ImdbAsync(string art, int kennung, CancellationToken ct)
{
var einstellung = await einstellungen.GetAsync(ct);
if (string.IsNullOrWhiteSpace(einstellung.ApiKey)) return null;
var fremde = await HolenAsync<TmdbExternalIds>($"{art}/{kennung}/external_ids", einstellung, ct);
return Leer(fremde?.ImdbId);
}
public async Task<EpisodeMetadata?> FindEpisodeAsync(
@@ -99,6 +153,22 @@ public sealed class TmdbClient(
return new EpisodeMetadata(Leer(folge.Name), Leer(folge.Overview), Bild(folge.StillPath, StillSize));
}
/// <summary>Die ganze Trefferliste — für die Auswahl von Hand.</summary>
private async Task<List<T>> AlleAsync<T>(
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<TmdbSearch<T>>(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<T?> SuchenAsync<T>(
string pfad, string titel, (string Name, string Wert)? zusatz,
Func<T, string?> 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<T>([property: JsonPropertyName("results")] List<T>? 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,
@@ -21,4 +21,16 @@ public sealed class AbgeschalteterAnbieter : IMetadataProvider
public Task<EpisodeMetadata?> FindEpisodeAsync(int s, int st, int f, CancellationToken ct = default) =>
throw new InvalidOperationException("In diesem Test darf nichts nachgeschlagen werden.");
public Task<MovieMetadata?> GetMovieAsync(int id, CancellationToken ct = default) =>
throw new InvalidOperationException("In diesem Test darf nichts nachgeschlagen werden.");
public Task<SeriesMetadata?> GetSeriesAsync(int id, CancellationToken ct = default) =>
throw new InvalidOperationException("In diesem Test darf nichts nachgeschlagen werden.");
public Task<IReadOnlyList<MovieMetadata>> SearchMoviesAsync(string t, int? j, CancellationToken ct = default) =>
throw new InvalidOperationException("In diesem Test darf nichts nachgeschlagen werden.");
public Task<IReadOnlyList<SeriesMetadata>> SearchSeriesAsync(string t, int? j, CancellationToken ct = default) =>
throw new InvalidOperationException("In diesem Test darf nichts nachgeschlagen werden.");
}
@@ -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;
/// <summary>Eine falsche Zuordnung richtigstellen — und sie festschreiben.</summary>
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) { }
}
/// <summary>Ein Anbieter, der zwei verschiedene Filme kennt.</summary>
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");
/// <summary>Ein Treffer mit <b>anderem Jahr</b> als im Dateinamen.</summary>
public static readonly MovieMetadata AnderesJahr =
new(613, "Der Untergang", 2004, "Berlin, April 1945.", null, null, "tt0363163");
public Task<bool> IsConfiguredAsync(CancellationToken ct = default) => Task.FromResult(true);
// Die Suche liefert den beliebteren zuerst — genau wie TMDB.
public Task<MovieMetadata?> FindMovieAsync(string t, int? j, CancellationToken ct = default) =>
Task.FromResult<MovieMetadata?>(Falsch);
public Task<IReadOnlyList<MovieMetadata>> SearchMoviesAsync(string t, int? j, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<MovieMetadata>>([Falsch, Richtig]);
public Task<MovieMetadata?> GetMovieAsync(int id, CancellationToken ct = default) =>
Task.FromResult<MovieMetadata?>(
id == Richtig.ProviderId ? Richtig
: id == Falsch.ProviderId ? Falsch
: id == AnderesJahr.ProviderId ? AnderesJahr
: null);
public Task<SeriesMetadata?> FindSeriesAsync(string t, int? j, CancellationToken ct = default) =>
Task.FromResult<SeriesMetadata?>(null);
public Task<IReadOnlyList<SeriesMetadata>> SearchSeriesAsync(string t, int? j, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<SeriesMetadata>>([]);
public Task<SeriesMetadata?> GetSeriesAsync(int id, CancellationToken ct = default) =>
Task.FromResult<SeriesMetadata?>(null);
public Task<EpisodeMetadata?> FindEpisodeAsync(int s, int st, int f, CancellationToken ct = default) =>
Task.FromResult<EpisodeMetadata?>(null);
}
private async Task<(HttpClient Client, MovieResponse Film)> Eingelesen(bool beiwerkSchreiben)
{
_server = new HaloServer(dienste =>
{
dienste.AddScoped<IMetadataProvider, ZweiTreffer>();
dienste.AddSingleton<ISidecarStore>(
new SidecarWriter(new SidecarOptions(beiwerkSchreiben),
Microsoft.Extensions.Logging.Abstractions.NullLogger<SidecarWriter>.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<LibraryResponse>())!;
(await client.PostAsync($"/api/v1/libraries/{bibliothek.Id}/scan", null)).EnsureSuccessStatusCode();
for (var i = 0; i < 150; i++)
{
var stand = await client.GetFromJsonAsync<ScanStatus>($"/api/v1/libraries/{bibliothek.Id}/scan");
if (stand!.State == "finished") break;
await Task.Delay(50);
}
var filme = await client.GetFromJsonAsync<Page<MovieResponse>>("/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<List<MatchCandidate>>($"/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<MatchState>();
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<Page<MovieResponse>>("/api/v1/movies");
Assert.That(danach!.Items.Single().Title, Is.EqualTo("Dark"));
}
/// <summary>Die Korrektur überlebt einen Neuaufbau der Datenbank.</summary>
///
/// <remarks>
/// <b>Darum geht es bei der NFO-Datei.</b> In der Datenbank allein wäre
/// die Entscheidung mit dem nächsten Umzug oder Neuaufbau verloren.
/// </remarks>
[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("<title>Dark</title>"));
}
[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));
}
/// <summary>Eine Korrektur muss den nächsten Scan überstehen.</summary>
///
/// <remarks>
/// <b>Live gefunden, und der Fehler war unangenehm.</b> 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
/// <c>ParsedYear</c>, das sich nie ändert.
/// </remarks>
[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<List<LibraryResponse>>("/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<ScanStatus>(
$"/api/v1/libraries/{bibliotheken.Single().Id}/scan");
if (stand!.State == "finished") break;
await Task.Delay(50);
}
var danach = await client.GetFromJsonAsync<Page<MovieResponse>>("/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));
}
}
@@ -38,6 +38,32 @@ public sealed class VorgegebenerAnbieter : IMetadataProvider
if (Wirft is not null) throw Wirft;
return Task.FromResult(Folge);
}
public Task<MovieMetadata?> GetMovieAsync(int id, CancellationToken ct = default)
{
Gefragt.Add($"filmId:{id}");
if (Wirft is not null) throw Wirft;
return Task.FromResult(Film);
}
public Task<SeriesMetadata?> GetSeriesAsync(int id, CancellationToken ct = default)
{
Gefragt.Add($"serieId:{id}");
if (Wirft is not null) throw Wirft;
return Task.FromResult(Serie);
}
public Task<IReadOnlyList<MovieMetadata>> SearchMoviesAsync(string t, int? j, CancellationToken ct = default)
{
Gefragt.Add($"suchFilm:{t}");
return Task.FromResult<IReadOnlyList<MovieMetadata>>(Film is null ? [] : [Film]);
}
public Task<IReadOnlyList<SeriesMetadata>> SearchSeriesAsync(string t, int? j, CancellationToken ct = default)
{
Gefragt.Add($"suchSerie:{t}");
return Task.FromResult<IReadOnlyList<SeriesMetadata>>(Serie is null ? [] : [Serie]);
}
}
/// <summary>Eine Bildablage, die nichts herunterlädt.</summary>
@@ -0,0 +1,223 @@
using Halo.Core.Library;
namespace Halo.Core.Tests;
/// <summary>Was Halo in einen Medienordner schreiben darf.</summary>
///
/// <remarks>
/// <b>Die wichtigste Prüfdatei des Projekts.</b> 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.
/// </remarks>
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);
}
/// <summary>Eine Mediendatei wird nie angefasst — auch nicht, wenn jemand es verlangt.</summary>
[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);
}
/// <summary>Kein Weg aus dem Ordner heraus.</summary>
///
/// <remarks>
/// 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.
/// </remarks>
[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);
}
/// <summary>Zwei Filme im selben Ordner dürfen sich nicht überschreiben.</summary>
[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"));
}
/// <summary>Was erzeugt wird, muss auch erlaubt sein.</summary>
///
/// <remarks>
/// 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.
/// </remarks>
[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("<?xml"));
Assert.That(xml, Does.Contain("<title>Der Untergang</title>"));
Assert.That(xml, Does.Contain("<year>2004</year>"));
var gelesen = Nfo.ReadIds(xml);
Assert.That((gelesen.Tmdb, gelesen.Imdb), Is.EqualTo((613, "tt0363163")));
Assert.That(Nfo.ReadLocked(xml), Is.True);
}
/// <summary>Kennungen stehen doppelt drin, für alte wie neue Werkzeuge.</summary>
[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("""<uniqueid type="tmdb" default="true">613</uniqueid>"""));
Assert.That(xml, Does.Contain("<tmdbid>613</tmdbid>"));
Assert.That(xml, Does.Contain("""<uniqueid type="imdb">tt0363163</uniqueid>"""));
Assert.That(xml, Does.Contain("<imdbid>tt0363163</imdbid>"));
}
[Test]
public void Die_aeltere_Form_allein_wird_auch_gelesen()
{
var gelesen = Nfo.ReadIds("<movie><tmdbid>613</tmdbid><imdbid>tt0363163</imdbid></movie>");
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 <der Film>", null, null, ProviderIds.None, false));
Assert.That(xml, Does.Contain("Tom &amp; Jerry &lt;der Film&gt;"));
Assert.That(Nfo.ReadIds(xml).Any, Is.False, "Und die Datei bleibt lesbar");
}
/// <summary>Eine von Hand geschriebene NFO ist oft kaputt — das darf nichts abbrechen.</summary>
[TestCase("")]
[TestCase("kein xml")]
[TestCase("<movie><title>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;</title><tmdbid>613</tmdbid></movie>
""";
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("<season>1</season>"));
Assert.That(xml, Does.Contain("<episode>1</episode>"));
Assert.That(xml, Does.Contain("<title>Geheimnisse</title>"));
}
}
@@ -0,0 +1,146 @@
using System.Text;
using Halo.Core.Library;
using Microsoft.Extensions.Logging.Abstractions;
namespace Halo.Media.Tests;
/// <summary>Was wirklich auf der Platte landet.</summary>
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<SidecarWriter>.Instance);
[Test]
public async Task Eine_NFO_landet_neben_dem_Film()
{
await Schreiber().WriteTextAsync(_film, SidecarNames.MovieNfo, "<movie><tmdbid>613</tmdbid></movie>");
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);
}
/// <summary>Die Prüfung, an der der Schutz des Bestands hängt.</summary>
///
/// <remarks>
/// <b>Es muss laut scheitern, nicht still übergehen.</b> 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.
/// </remarks>
[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<InvalidOperationException>(
() => Schreiber().WriteTextAsync(_film, versuch, "egal"));
Assert.That(fehler!.Message, Does.Contain("Liste"));
}
/// <summary>Die Mediendatei bleibt unberührt — auch beim Versuch.</summary>
[Test]
public void Die_Mediendatei_wird_nie_angefasst()
{
var vorher = new FileInfo(_film).Length;
Assert.ThrowsAsync<InvalidOperationException>(
() => 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);
}
/// <summary>Abgeschaltet heisst abgeschaltet.</summary>
[Test]
public async Task Ohne_Erlaubnis_wird_nichts_geschrieben()
{
await Schreiber(an: false).WriteTextAsync(_film, SidecarNames.MovieNfo, "<movie/>");
Assert.That(File.Exists(Path.Combine(_ordner, "movie.nfo")), Is.False);
Assert.That(Schreiber(an: false).Enabled, Is.False);
}
/// <summary>Gelesen wird trotzdem.</summary>
///
/// <remarks>
/// Eine vorhandene NFO ist eine Zuordnung, die jemand getroffen hat. Sie
/// zu übergehen, bloss weil Halo selbst nicht schreiben darf, wäre
/// verkehrt herum.
/// </remarks>
[Test]
public async Task Gelesen_wird_auch_ohne_Schreiberlaubnis()
{
await File.WriteAllTextAsync(Path.Combine(_ordner, "movie.nfo"), "<movie><tmdbid>613</tmdbid></movie>");
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, "<movie>alt</movie>");
await Schreiber().WriteTextAsync(_film, SidecarNames.MovieNfo, "<movie>neu</movie>");
Assert.That(await File.ReadAllTextAsync(Path.Combine(_ordner, "movie.nfo")), Is.EqualTo("<movie>neu</movie>"));
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);
}
/// <summary>Beiwerk zählt nicht als zweiter Film.</summary>
[Test]
public async Task Das_eigene_Beiwerk_macht_den_Film_nicht_unselbstaendig()
{
await Schreiber().WriteTextAsync(_film, SidecarNames.MovieNfo, "<movie/>");
using var bild = new MemoryStream([1, 2, 3]);
await Schreiber().WriteStreamAsync(_film, SidecarNames.MoviePoster, bild);
Assert.That(Schreiber().IsAloneInFolder(_film), Is.True);
}
}
@@ -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]
@@ -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));
}
/// <summary>Der ältere Schlüssel kann nur in die Adresse — also tut er das.</summary>
@@ -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"));
}
}