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
@@ -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"));
}
}