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>
|
||||
|
||||
Reference in New Issue
Block a user