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>
403 lines
16 KiB
C#
403 lines
16 KiB
C#
using System.Net;
|
|
using System.Net.Http.Json;
|
|
using Halo.Api.Endpoints;
|
|
using Halo.Core.Metadata;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
|
|
namespace Halo.Api.Tests;
|
|
|
|
/// <summary>Ein Anbieter, der antwortet, was der Test vorgibt.</summary>
|
|
public sealed class VorgegebenerAnbieter : IMetadataProvider
|
|
{
|
|
public bool Konfiguriert { get; set; } = true;
|
|
public Exception? Wirft { get; set; }
|
|
public MovieMetadata? Film { get; set; }
|
|
public SeriesMetadata? Serie { get; set; }
|
|
public EpisodeMetadata? Folge { get; set; }
|
|
public List<string> Gefragt { get; } = [];
|
|
|
|
public Task<bool> IsConfiguredAsync(CancellationToken ct = default) => Task.FromResult(Konfiguriert);
|
|
|
|
public Task<MovieMetadata?> FindMovieAsync(string titel, int? jahr, CancellationToken ct = default)
|
|
{
|
|
Gefragt.Add($"film:{titel}:{jahr}");
|
|
if (Wirft is not null) throw Wirft;
|
|
return Task.FromResult(Film);
|
|
}
|
|
|
|
public Task<SeriesMetadata?> FindSeriesAsync(string titel, int? jahr, CancellationToken ct = default)
|
|
{
|
|
Gefragt.Add($"serie:{titel}");
|
|
if (Wirft is not null) throw Wirft;
|
|
return Task.FromResult(Serie);
|
|
}
|
|
|
|
public Task<EpisodeMetadata?> FindEpisodeAsync(int serie, int staffel, int folge, CancellationToken ct = default)
|
|
{
|
|
Gefragt.Add($"folge:{serie}:{staffel}:{folge}");
|
|
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>
|
|
public sealed class VorgegebeneBilder : IImageStore
|
|
{
|
|
public List<string> Geholt { get; } = [];
|
|
|
|
public Task<string?> StoreAsync(RemoteImage bild, CancellationToken ct = default)
|
|
{
|
|
Geholt.Add(bild.Url);
|
|
return Task.FromResult<string?>(new string('a', 64));
|
|
}
|
|
}
|
|
|
|
public class MetadataTests
|
|
{
|
|
private string _bestand = "";
|
|
|
|
[SetUp]
|
|
public void Anlegen() => _bestand = Directory.CreateDirectory(
|
|
Path.Combine(Path.GetTempPath(), "halo-meta-" + Guid.CreateVersion7().ToString("N"))).FullName;
|
|
|
|
[TearDown]
|
|
public void Aufraeumen()
|
|
{
|
|
try { Directory.Delete(_bestand, recursive: true); } catch (IOException) { }
|
|
}
|
|
|
|
private void Datei(string relativ)
|
|
{
|
|
var voll = Path.Combine(_bestand, relativ);
|
|
Directory.CreateDirectory(Path.GetDirectoryName(voll)!);
|
|
File.WriteAllBytes(voll, new byte[1024]);
|
|
}
|
|
|
|
private HaloServer MitAnbieter(VorgegebenerAnbieter anbieter, VorgegebeneBilder? bilder = null) =>
|
|
new(dienste =>
|
|
{
|
|
dienste.AddScoped<IMetadataProvider>(_ => anbieter);
|
|
dienste.AddScoped<IImageStore>(_ => bilder ?? new VorgegebeneBilder());
|
|
});
|
|
|
|
private async Task<(HttpClient Admin, Guid Library)> Einrichten(HaloServer server, string art)
|
|
{
|
|
var admin = await server.SetUpAndSignIn();
|
|
var client = server.AlsAngemeldeter(admin.Token);
|
|
var angelegt = await client.PostAsJsonAsync("/api/v1/libraries",
|
|
new CreateLibraryRequest("Prüfbestand", art, _bestand));
|
|
angelegt.EnsureSuccessStatusCode();
|
|
return (client, (await angelegt.Content.ReadFromJsonAsync<LibraryResponse>())!.Id);
|
|
}
|
|
|
|
private static async Task<ScanStatus> Scannen(HttpClient client, Guid bibliothek)
|
|
{
|
|
(await client.PostAsync($"/api/v1/libraries/{bibliothek}/scan", null)).EnsureSuccessStatusCode();
|
|
for (var i = 0; i < 100; i++)
|
|
{
|
|
var stand = await client.GetFromJsonAsync<ScanStatus>($"/api/v1/libraries/{bibliothek}/scan");
|
|
if (stand!.State == "finished") return stand;
|
|
if (stand.State == "failed") Assert.Fail($"Gescheitert: {stand.Error}");
|
|
await Task.Delay(50);
|
|
}
|
|
Assert.Fail("Nicht fertig geworden.");
|
|
return null!;
|
|
}
|
|
|
|
[Test]
|
|
public async Task Der_Titel_aus_der_Quelle_ersetzt_den_aus_dem_Dateinamen()
|
|
{
|
|
Datei("The.Matrix.1999.1080p.mkv");
|
|
var anbieter = new VorgegebenerAnbieter
|
|
{
|
|
Film = new MovieMetadata(603, "Matrix", 1999, "Neo zweifelt.",
|
|
new RemoteImage("https://bild/p.jpg"), new RemoteImage("https://bild/b.jpg")),
|
|
};
|
|
var bilder = new VorgegebeneBilder();
|
|
|
|
using var server = MitAnbieter(anbieter, bilder);
|
|
var (client, bibliothek) = await Einrichten(server, "Movies");
|
|
var stand = await Scannen(client, bibliothek);
|
|
|
|
Assert.That(stand.Metadata!.MoviesMatched, Is.EqualTo(1));
|
|
Assert.That(anbieter.Gefragt, Does.Contain("film:The Matrix:1999"));
|
|
|
|
var filme = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies");
|
|
Assert.That(filme!.Items[0].Title, Is.EqualTo("Matrix"));
|
|
Assert.That(filme.Items[0].Overview, Is.EqualTo("Neo zweifelt."));
|
|
Assert.That(filme.Items[0].PosterImage, Is.Not.Null);
|
|
Assert.That(bilder.Geholt, Is.EquivalentTo(new[] { "https://bild/p.jpg", "https://bild/b.jpg" }));
|
|
}
|
|
|
|
/// <summary>Was die Quelle nicht kennt, bleibt trotzdem in der Mediathek.</summary>
|
|
[Test]
|
|
public async Task Ein_Film_ohne_Treffer_behaelt_seinen_Dateinamen()
|
|
{
|
|
Datei("Urlaub Kreta 2019.mkv");
|
|
using var server = MitAnbieter(new VorgegebenerAnbieter { Film = null });
|
|
var (client, bibliothek) = await Einrichten(server, "Movies");
|
|
await Scannen(client, bibliothek);
|
|
|
|
var filme = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies");
|
|
Assert.That(filme!.Total, Is.EqualTo(1));
|
|
Assert.That(filme.Items[0].Title, Is.EqualTo("Urlaub Kreta"));
|
|
}
|
|
|
|
/// <summary>Ohne Schlüssel läuft der Scan trotzdem durch.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// Das ist der Normalfall beim allerersten Start: Bibliothek angelegt,
|
|
/// Schlüssel noch nicht. Die Mediathek muss dann aus Dateinamen bestehen
|
|
/// und nicht leer sein.
|
|
/// </remarks>
|
|
[Test]
|
|
public async Task Ohne_Schluessel_bleibt_es_bei_den_Dateinamen()
|
|
{
|
|
Datei("The Matrix (1999).mkv");
|
|
var anbieter = new VorgegebenerAnbieter { Konfiguriert = false };
|
|
|
|
using var server = MitAnbieter(anbieter);
|
|
var (client, bibliothek) = await Einrichten(server, "Movies");
|
|
var stand = await Scannen(client, bibliothek);
|
|
|
|
Assert.That(stand.Metadata!.Skipped, Is.True);
|
|
Assert.That(anbieter.Gefragt, Is.Empty);
|
|
Assert.That((await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies"))!.Total, Is.EqualTo(1));
|
|
}
|
|
|
|
/// <summary>Ein falscher Schlüssel steht im Scanbericht, nicht nur im Protokoll.</summary>
|
|
[Test]
|
|
public async Task Ein_zurueckgewiesener_Schluessel_erscheint_im_Bericht()
|
|
{
|
|
Datei("The Matrix (1999).mkv");
|
|
var anbieter = new VorgegebenerAnbieter
|
|
{
|
|
Wirft = new MetadataCredentialsException("TMDB weist den hinterlegten Schlüssel zurück."),
|
|
};
|
|
|
|
using var server = MitAnbieter(anbieter);
|
|
var (client, bibliothek) = await Einrichten(server, "Movies");
|
|
var stand = await Scannen(client, bibliothek);
|
|
|
|
Assert.That(stand.Metadata!.Error, Does.Contain("Schlüssel"));
|
|
// Die Datei ist trotzdem da — das Nachschlagen scheiterte, nicht der Scan.
|
|
Assert.That((await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies"))!.Total, Is.EqualTo(1));
|
|
}
|
|
|
|
[Test]
|
|
public async Task Ein_zweiter_Lauf_fragt_nicht_noch_einmal()
|
|
{
|
|
Datei("The Matrix (1999).mkv");
|
|
var anbieter = new VorgegebenerAnbieter
|
|
{
|
|
Film = new MovieMetadata(603, "Matrix", 1999, "Neo.", null, null),
|
|
};
|
|
|
|
using var server = MitAnbieter(anbieter);
|
|
var (client, bibliothek) = await Einrichten(server, "Movies");
|
|
await Scannen(client, bibliothek);
|
|
var anzahlNachErstem = anbieter.Gefragt.Count;
|
|
await Scannen(client, bibliothek);
|
|
|
|
Assert.That(anbieter.Gefragt, Has.Count.EqualTo(anzahlNachErstem),
|
|
"Ein zugeordneter Titel wird nicht erneut nachgeschlagen");
|
|
}
|
|
|
|
[Test]
|
|
public async Task Serien_und_Folgen_werden_nachgeschlagen()
|
|
{
|
|
Datei("Dark/Dark - S01E01.mkv");
|
|
var anbieter = new VorgegebenerAnbieter
|
|
{
|
|
Serie = new SeriesMetadata(70523, "Dark", 2017, "Vier Familien.", null, null),
|
|
Folge = new EpisodeMetadata("Geheimnisse", "Ein Junge verschwindet.", null),
|
|
};
|
|
|
|
using var server = MitAnbieter(anbieter);
|
|
var (client, bibliothek) = await Einrichten(server, "Shows");
|
|
var stand = await Scannen(client, bibliothek);
|
|
|
|
Assert.That(stand.Metadata!.SeriesMatched, Is.EqualTo(1));
|
|
Assert.That(stand.Metadata.EpisodesMatched, Is.EqualTo(1));
|
|
Assert.That(anbieter.Gefragt, Does.Contain("folge:70523:1:1"));
|
|
|
|
var serien = await client.GetFromJsonAsync<Page<SeriesResponse>>("/api/v1/series");
|
|
Assert.That(serien!.Items[0].Overview, Is.EqualTo("Vier Familien."));
|
|
|
|
var folgen = await client.GetFromJsonAsync<Page<EpisodeResponse>>(
|
|
$"/api/v1/series/{serien.Items[0].Id}/episodes");
|
|
Assert.That(folgen!.Items[0].Title, Is.EqualTo("Geheimnisse"));
|
|
Assert.That(folgen.Items[0].Overview, Is.EqualTo("Ein Junge verschwindet."));
|
|
}
|
|
|
|
/// <summary>Folgen einer Serie ohne Zuordnung werden gar nicht erst gefragt.</summary>
|
|
[Test]
|
|
public async Task Ohne_zugeordnete_Serie_werden_keine_Folgen_gefragt()
|
|
{
|
|
Datei("Unbekannt/Unbekannt - S01E01.mkv");
|
|
var anbieter = new VorgegebenerAnbieter { Serie = null };
|
|
|
|
using var server = MitAnbieter(anbieter);
|
|
var (client, bibliothek) = await Einrichten(server, "Shows");
|
|
await Scannen(client, bibliothek);
|
|
|
|
Assert.That(anbieter.Gefragt.Where(g => g.StartsWith("folge:")), Is.Empty);
|
|
}
|
|
}
|
|
|
|
public class MetadataSettingsTests
|
|
{
|
|
/// <summary>Der Schlüssel darf nie zurückkommen.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// Er gäbe jedem Angemeldeten das TMDB-Kontingent des Betreibers in die
|
|
/// Hand. Zum Anzeigen genügt die Auskunft, dass einer da ist.
|
|
/// </remarks>
|
|
[Test]
|
|
public async Task Der_Schluessel_kommt_nicht_zurueck()
|
|
{
|
|
using var server = new HaloServer();
|
|
var admin = await server.SetUpAndSignIn();
|
|
var client = server.AlsAngemeldeter(admin.Token);
|
|
|
|
await client.PutAsJsonAsync("/api/v1/settings/metadata",
|
|
new MetadataSettingsRequest("geheimer-schluessel", "de-DE"));
|
|
|
|
var antwort = await client.GetAsync("/api/v1/settings/metadata");
|
|
var roh = await antwort.Content.ReadAsStringAsync();
|
|
|
|
Assert.That(roh, Does.Not.Contain("geheimer-schluessel"));
|
|
var stand = await antwort.Content.ReadFromJsonAsync<MetadataSettingsResponse>();
|
|
Assert.That(stand!.HasApiKey, Is.True);
|
|
Assert.That(stand.Language, Is.EqualTo("de-DE"));
|
|
}
|
|
|
|
/// <summary>Ohne eigenen Schlüssel greift der mitgelieferte.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <c>UsingProjectKey</c> ist der Unterschied, den die Oberfläche zeigen
|
|
/// muss: „es läuft, aber über das gemeinsame Kontingent" ist etwas anderes
|
|
/// als „du hast einen eigenen hinterlegt".
|
|
/// </remarks>
|
|
[Test]
|
|
public async Task Ohne_eigenen_Schluessel_greift_der_mitgelieferte()
|
|
{
|
|
using var server = new HaloServer();
|
|
var admin = await server.SetUpAndSignIn();
|
|
var stand = await server.AlsAngemeldeter(admin.Token)
|
|
.GetFromJsonAsync<MetadataSettingsResponse>("/api/v1/settings/metadata");
|
|
|
|
var mitgeliefert = Halo.Metadata.TmdbSettings.ProjectApiKey.Length > 0;
|
|
Assert.That(stand!.HasApiKey, Is.EqualTo(mitgeliefert));
|
|
Assert.That(stand.UsingProjectKey, Is.EqualTo(mitgeliefert));
|
|
Assert.That(stand.Language, Is.EqualTo("en-US"));
|
|
}
|
|
|
|
[Test]
|
|
public async Task Ein_eigener_Schluessel_loest_den_mitgelieferten_ab()
|
|
{
|
|
using var server = new HaloServer();
|
|
var admin = await server.SetUpAndSignIn();
|
|
var client = server.AlsAngemeldeter(admin.Token);
|
|
|
|
await client.PutAsJsonAsync("/api/v1/settings/metadata",
|
|
new MetadataSettingsRequest("mein-eigener-schluessel", "de-DE"));
|
|
|
|
var stand = await client.GetFromJsonAsync<MetadataSettingsResponse>("/api/v1/settings/metadata");
|
|
Assert.That(stand!.HasApiKey, Is.True);
|
|
Assert.That(stand.UsingProjectKey, Is.False);
|
|
}
|
|
|
|
/// <summary>TMDB verlangt den Hinweis — und zwar ohne Anmeldung abrufbar.</summary>
|
|
[Test]
|
|
public async Task Der_Pflichthinweis_zu_TMDB_steht_in_der_Systemauskunft()
|
|
{
|
|
using var server = new HaloServer();
|
|
var info = await server.Client.GetFromJsonAsync<SystemInfo>("/api/v1/system/info");
|
|
|
|
var tmdb = info!.Attributions.Single();
|
|
Assert.That(tmdb.Text, Does.Contain("TMDB").And.Contain("not endorsed"));
|
|
Assert.That(tmdb.LogoUrl, Does.StartWith("https://"));
|
|
}
|
|
|
|
[TestCase("deutsch")]
|
|
[TestCase("de_DE")]
|
|
[TestCase("DE")]
|
|
[TestCase("de-de")]
|
|
public async Task Eine_unsinnige_Sprachkennung_wird_abgelehnt(string sprache)
|
|
{
|
|
using var server = new HaloServer();
|
|
var admin = await server.SetUpAndSignIn();
|
|
|
|
var antwort = await server.AlsAngemeldeter(admin.Token)
|
|
.PutAsJsonAsync("/api/v1/settings/metadata", new MetadataSettingsRequest(null, sprache));
|
|
|
|
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
|
|
}
|
|
|
|
[Test]
|
|
public async Task Ein_gewoehnlicher_Benutzer_sieht_die_Einstellungen_nicht()
|
|
{
|
|
using var server = new HaloServer();
|
|
var admin = await server.SetUpAndSignIn();
|
|
await server.AlsAngemeldeter(admin.Token).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).GetAsync("/api/v1/settings/metadata");
|
|
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden));
|
|
}
|
|
}
|
|
|
|
public class ImageEndpointTests
|
|
{
|
|
[Test]
|
|
public async Task Bilder_gibt_es_nur_fuer_Angemeldete()
|
|
{
|
|
using var server = new HaloServer();
|
|
await server.SetUpAndSignIn();
|
|
|
|
var antwort = await server.Client.GetAsync($"/api/v1/images/{new string('a', 64)}");
|
|
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
|
}
|
|
|
|
/// <summary>Der Weg, der sonst die Datenbank herausgäbe.</summary>
|
|
[TestCase("..%2F..%2Fconfig%2Fhalo.db")]
|
|
[TestCase("nichtsechzigvierzeichen")]
|
|
[TestCase("ZZZZ567890123456789012345678901234567890123456789012345678901234")]
|
|
public async Task Ein_Schluessel_der_keiner_ist_fuehrt_ins_Leere(string versuch)
|
|
{
|
|
using var server = new HaloServer();
|
|
var admin = await server.SetUpAndSignIn();
|
|
|
|
var antwort = await server.AlsAngemeldeter(admin.Token).GetAsync($"/api/v1/images/{versuch}");
|
|
Assert.That(antwort.StatusCode, Is.AnyOf(HttpStatusCode.NotFound, HttpStatusCode.BadRequest));
|
|
}
|
|
}
|