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:
co-authored by
Claude Opus 5
parent
79b6001f86
commit
f98b995050
@@ -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 & Jerry <der Film>"));
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user