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>
250 lines
11 KiB
C#
250 lines
11 KiB
C#
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));
|
|
}
|
|
}
|