Der Scan verglich den Titel aus dem Dateinamen zeichengenau. Ein Punkt Unterschied — Dr. Dolittle 2 gegen Dr Dolittle 2 —, oder eine Datei mit Jahresangabe neben einer ohne, und es entstanden zwei Einträge. Die Metadaten ordneten beide demselben TMDB-Eintrag zu, beide bekamen denselben Titel, dasselbe Jahr, dasselbe Plakat, und nichts führte sie je wieder zusammen. Drei Ebenen, weil eine nicht trägt. Titelschluessel vergleicht ohne Satzzeichen, ohne Akzente und ohne Grossschreibung; ein fehlendes Jahr passt auf jedes, zwei bekannte verschiedene trennen. Bewusst nicht aggressiver: Spider-Man und Spiderman bleiben getrennt, und die beiden Dune von 1984 und 2021 sowieso. Ein zusammengelegter Film lässt sich nicht mehr trennen, ohne zu wissen, welche Datei wohin gehörte. Ein gefilterter Eindeutigkeitsindex auf (LibraryId, TmdbId) stellt die Regel in die Datenbank statt in Code, der sie einhalten soll. Gefiltert, weil ohne Metadaten alle Einträge null tragen. Zusammenlegung räumt auf, was kein Vergleich erkennen könnte: Ich einfach unverbesserlich und Despicable Me sind derselbe Film, und das verrät allein die TMDB-Kennung. Läuft nach jedem Scan, und von Hand über einen Knopf in der Verwaltung. Der Sehfortschritt wird gefaltet, nicht umgehängt — die weitere Stelle gewinnt, gesehen gewinnt, weggelegt gewinnt nicht. Die Migration räumt zuerst auf und legt dann erst den Index an. Andersherum scheiterte sie auf jeder Datenbank mit Dubletten, und zwar beim Start im Container, wo die Meldung niemand sieht. Gemessen an einer Datenbank mit zwei Dubletten und zwei Sehständen: 3 Filme auf 2, beide Dateien erhalten, Position 1800 statt 300 behalten. Dazu Fassungen. MediaFile.VersionLabel liest Mobil, 4K oder Director's Cut aus dem Namen, sonst aus der gemessenen Bildhöhe. Die Liste ist geschlossen, damit eine Fassung nicht YIFY heisst. Und der Fassungsname beendet jetzt den Titel wie jedes andere Beiwerk — vorher wurde aus "The Matrix (1999) - Mobil.mp4" der Film "The Matrix - Mobil", also ein zweiter Eintrag neben dem Original. Genau die Dublette, um die es ging. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
344 lines
14 KiB
C#
344 lines
14 KiB
C#
using System.Net;
|
||
using System.Net.Http.Json;
|
||
using Flaro.Api.Endpoints;
|
||
|
||
namespace Flaro.Api.Tests;
|
||
|
||
/// <summary>Scan gegen einen echten Ordner mit echten Dateien.</summary>
|
||
public class ScanTests
|
||
{
|
||
private string _bestand = "";
|
||
|
||
[SetUp]
|
||
public void Anlegen() => _bestand = Directory.CreateDirectory(
|
||
Path.Combine(Path.GetTempPath(), "flaro-medien-" + Guid.CreateVersion7().ToString("N"))).FullName;
|
||
|
||
[TearDown]
|
||
public void Aufraeumen()
|
||
{
|
||
try { Directory.Delete(_bestand, recursive: true); } catch (IOException) { }
|
||
}
|
||
|
||
private void Datei(string relativ, int bytes = 2048)
|
||
{
|
||
var voll = Path.Combine(_bestand, relativ);
|
||
Directory.CreateDirectory(Path.GetDirectoryName(voll)!);
|
||
File.WriteAllBytes(voll, new byte[bytes]);
|
||
}
|
||
|
||
private async Task<(HttpClient Admin, Guid LibraryId)> MitBibliothek(FlaroServer 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();
|
||
var bibliothek = (await angelegt.Content.ReadFromJsonAsync<LibraryResponse>())!;
|
||
return (client, bibliothek.Id);
|
||
}
|
||
|
||
/// <summary>Stösst an und wartet, bis der Hintergrundlauf fertig ist.</summary>
|
||
private static async Task<ScanStatus> Scannen(HttpClient client, Guid bibliothek)
|
||
{
|
||
var angestossen = await client.PostAsync($"/api/v1/libraries/{bibliothek}/scan", null);
|
||
Assert.That(angestossen.StatusCode, Is.EqualTo(HttpStatusCode.Accepted));
|
||
|
||
for (var versuch = 0; versuch < 100; versuch++)
|
||
{
|
||
var stand = await client.GetFromJsonAsync<ScanStatus>($"/api/v1/libraries/{bibliothek}/scan");
|
||
if (stand!.State is "finished") return stand;
|
||
if (stand.State is "failed") Assert.Fail($"Der Scan ist gescheitert: {stand.Error}");
|
||
await Task.Delay(50);
|
||
}
|
||
Assert.Fail("Der Scan wurde in fünf Sekunden nicht fertig.");
|
||
return null!;
|
||
}
|
||
|
||
[Test]
|
||
public async Task Filme_werden_gefunden_und_benannt()
|
||
{
|
||
Datei("The.Matrix.1999.1080p.BluRay.x264-GRP.mkv");
|
||
Datei("Der Untergang (2004).mkv");
|
||
Datei("The Matrix (1999).srt"); // zählt nicht
|
||
Datei("@eaDir/The.Matrix.1999.mkv"); // zählt auch nicht
|
||
|
||
using var server = new FlaroServer();
|
||
var (client, bibliothek) = await MitBibliothek(server, "Movies");
|
||
|
||
var stand = await Scannen(client, bibliothek);
|
||
Assert.That(stand.FilesFound, Is.EqualTo(2));
|
||
Assert.That(stand.Added, Is.EqualTo(2));
|
||
|
||
var filme = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies");
|
||
Assert.That(filme!.Total, Is.EqualTo(2));
|
||
// Sortiert ohne Artikel: „Matrix" vor „Untergang".
|
||
Assert.That(filme.Items.Select(m => m.Title),
|
||
Is.EqualTo(new[] { "The Matrix", "Der Untergang" }));
|
||
Assert.That(filme.Items[0].Year, Is.EqualTo(1999));
|
||
}
|
||
|
||
/// <summary>Zweimal scannen muss dasselbe ergeben wie einmal.</summary>
|
||
///
|
||
/// <remarks>
|
||
/// <b>Sonst wächst die Mediathek bei jedem Lauf.</b> Das ist der Fehler,
|
||
/// den man erst nach Wochen bemerkt — wenn jeder Film fünfmal dasteht.
|
||
/// </remarks>
|
||
[Test]
|
||
public async Task Ein_zweiter_Lauf_aendert_nichts()
|
||
{
|
||
Datei("The Matrix (1999).mkv");
|
||
Datei("Inception (2010).mkv");
|
||
|
||
using var server = new FlaroServer();
|
||
var (client, bibliothek) = await MitBibliothek(server, "Movies");
|
||
|
||
await Scannen(client, bibliothek);
|
||
var zweiter = await Scannen(client, bibliothek);
|
||
|
||
Assert.That(zweiter.Added, Is.EqualTo(0));
|
||
Assert.That(zweiter.Removed, Is.EqualTo(0));
|
||
Assert.That((await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies"))!.Total, Is.EqualTo(2));
|
||
}
|
||
|
||
[Test]
|
||
public async Task Eine_geloeschte_Datei_verschwindet_aus_der_Mediathek()
|
||
{
|
||
Datei("The Matrix (1999).mkv");
|
||
Datei("Inception (2010).mkv");
|
||
|
||
using var server = new FlaroServer();
|
||
var (client, bibliothek) = await MitBibliothek(server, "Movies");
|
||
await Scannen(client, bibliothek);
|
||
|
||
File.Delete(Path.Combine(_bestand, "Inception (2010).mkv"));
|
||
var danach = await Scannen(client, bibliothek);
|
||
|
||
Assert.That(danach.Removed, Is.EqualTo(1));
|
||
var filme = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies");
|
||
Assert.That(filme!.Items.Select(m => m.Title), Is.EqualTo(new[] { "The Matrix" }));
|
||
}
|
||
|
||
/// <summary>Das Aufräumen darf nicht wegräumen, was derselbe Lauf schrieb.</summary>
|
||
///
|
||
/// <remarks>
|
||
/// <b>Mehr als fünfzig Dateien, und das ist der Punkt.</b> Seit der Scan
|
||
/// alle fünfzig Dateien zwischenspeichert, liegt ein Teil des Bestands
|
||
/// schon in der Datenbank, während der Lauf noch läuft. Direkt danach
|
||
/// löscht er alles, was eine ältere <c>SeenAt</c>-Zeit trägt als dieser
|
||
/// Lauf — würde die Zeit irgendwo neu genommen statt vor der Schleife
|
||
/// festgehalten, risse dieser Schritt den halben Bestand mit.
|
||
///
|
||
/// Die Dateien hier sind Nullbytes, an denen ffprobe scheitert. Damit
|
||
/// greift das Zwischenspeichern aus der Schleife und nicht das aus der
|
||
/// Untersuchung — also genau der Weg, der sonst ungeprüft bliebe.
|
||
/// </remarks>
|
||
[Test]
|
||
public async Task Ein_grosser_Bestand_ueberlebt_das_Aufraeumen()
|
||
{
|
||
for (var i = 1; i <= 120; i++) Datei($"Prüffilm {i:000} (2024).mkv");
|
||
|
||
using var server = new FlaroServer();
|
||
var (client, bibliothek) = await MitBibliothek(server, "Movies");
|
||
|
||
var erster = await Scannen(client, bibliothek);
|
||
|
||
Assert.That(erster.Added, Is.EqualTo(120));
|
||
Assert.That(erster.Removed, Is.Zero, "Ein erster Lauf darf nichts entfernen.");
|
||
Assert.That((await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies"))!.Total,
|
||
Is.EqualTo(120));
|
||
|
||
// Und ein zweiter Lauf bleibt ebenso vollständig.
|
||
var zweiter = await Scannen(client, bibliothek);
|
||
Assert.That(zweiter.Removed, Is.Zero);
|
||
Assert.That((await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies"))!.Total,
|
||
Is.EqualTo(120));
|
||
}
|
||
|
||
/// <summary>Schreibvarianten desselben Titels ergeben einen Eintrag.</summary>
|
||
///
|
||
/// <remarks>
|
||
/// <b>Der Fall, der die Mediathek doppelt aussehen liess.</b> Der Scan
|
||
/// verglich den Titel aus dem Dateinamen zeichengenau — ein Punkt
|
||
/// Unterschied, und es wurden zwei Filme, die anschliessend beide
|
||
/// denselben TMDB-Eintrag bekamen und ununterscheidbar nebeneinander
|
||
/// standen.
|
||
/// </remarks>
|
||
[Test]
|
||
public async Task Schreibvarianten_ergeben_einen_Film()
|
||
{
|
||
Datei("Dr. Dolittle 2 (2001).mkv");
|
||
Datei("Dr Dolittle 2.mkv");
|
||
|
||
using var server = new FlaroServer();
|
||
var (client, bibliothek) = await MitBibliothek(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].Files, Has.Count.EqualTo(2));
|
||
Assert.That(filme.Items[0].Year, Is.EqualTo(2001),
|
||
"Das Jahr der einen Datei gilt auch, wenn die andere keines trägt.");
|
||
}
|
||
|
||
/// <summary>Eine mobile Fassung ist kein eigener Film.</summary>
|
||
[Test]
|
||
public async Task Eine_optimierte_Fassung_bleibt_beim_Film()
|
||
{
|
||
Datei("The Matrix (1999).mkv");
|
||
Datei("The Matrix (1999) - Mobil.mp4");
|
||
|
||
using var server = new FlaroServer();
|
||
var (client, bibliothek) = await MitBibliothek(server, "Movies");
|
||
await Scannen(client, bibliothek);
|
||
|
||
var filme = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies");
|
||
Assert.That(filme!.Total, Is.EqualTo(1), "Sonst stünde ein zweiter Film ‚The Matrix - Mobil‘ daneben.");
|
||
Assert.That(filme.Items[0].Title, Is.EqualTo("The Matrix"));
|
||
Assert.That(filme.Items[0].Files, Has.Count.EqualTo(2));
|
||
}
|
||
|
||
/// <summary>Gleicher Name, anderes Jahr — zwei Filme, und das muss so bleiben.</summary>
|
||
[Test]
|
||
public async Task Zwei_Verfilmungen_bleiben_zwei_Filme()
|
||
{
|
||
Datei("Dune (1984).mkv");
|
||
Datei("Dune (2021).mkv");
|
||
|
||
using var server = new FlaroServer();
|
||
var (client, bibliothek) = await MitBibliothek(server, "Movies");
|
||
await Scannen(client, bibliothek);
|
||
|
||
var filme = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies");
|
||
Assert.That(filme!.Total, Is.EqualTo(2));
|
||
}
|
||
|
||
/// <summary>4K und 1080p sind ein Film mit zwei Dateien, nicht zwei Filme.</summary>
|
||
[Test]
|
||
public async Task Zwei_Fassungen_desselben_Films_bleiben_ein_Eintrag()
|
||
{
|
||
Datei("The Matrix (1999) - 1080p.mkv");
|
||
Datei("The Matrix (1999) - 2160p.mkv");
|
||
|
||
using var server = new FlaroServer();
|
||
var (client, bibliothek) = await MitBibliothek(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].Files, Has.Count.EqualTo(2));
|
||
}
|
||
|
||
[Test]
|
||
public async Task Serien_werden_in_Staffeln_und_Folgen_zerlegt()
|
||
{
|
||
Datei("Dark/Staffel 1/Dark - S01E01 - Geheimnisse.mkv");
|
||
Datei("Dark/Staffel 1/Dark - S01E02 - Lügen.mkv");
|
||
Datei("Dark/Staffel 2/Dark - S02E01 - Anfänge.mkv");
|
||
Datei("Notruf.Hafenkante.S01E01.German.1080p.mkv");
|
||
|
||
using var server = new FlaroServer();
|
||
var (client, bibliothek) = await MitBibliothek(server, "Shows");
|
||
await Scannen(client, bibliothek);
|
||
|
||
var serien = await client.GetFromJsonAsync<Page<SeriesResponse>>("/api/v1/series");
|
||
Assert.That(serien!.Total, Is.EqualTo(2));
|
||
|
||
var dark = serien.Items.Single(s => s.Title == "Dark");
|
||
Assert.That(dark.EpisodeCount, Is.EqualTo(3));
|
||
Assert.That(dark.Seasons, Is.EqualTo(new[] { 1, 2 }));
|
||
|
||
var folgen = await client.GetFromJsonAsync<Page<EpisodeResponse>>($"/api/v1/series/{dark.Id}/episodes");
|
||
Assert.That(folgen!.Items.Select(f => f.Title),
|
||
Is.EqualTo(new[] { "Geheimnisse", "Lügen", "Anfänge" }));
|
||
|
||
var nurStaffel2 = await client.GetFromJsonAsync<Page<EpisodeResponse>>(
|
||
$"/api/v1/series/{dark.Id}/episodes?season=2");
|
||
Assert.That(nurStaffel2!.Items, Has.Count.EqualTo(1));
|
||
}
|
||
|
||
/// <summary>Was keine Folgennummer trägt, wird nicht geraten.</summary>
|
||
[Test]
|
||
public async Task Dateien_ohne_Folgennummer_werden_gezaehlt_aber_nicht_zugeordnet()
|
||
{
|
||
Datei("Dark/Dark - S01E01.mkv");
|
||
Datei("Dark/Irgendein Mitschnitt.mkv");
|
||
|
||
using var server = new FlaroServer();
|
||
var (client, bibliothek) = await MitBibliothek(server, "Shows");
|
||
var stand = await Scannen(client, bibliothek);
|
||
|
||
Assert.That(stand.FilesFound, Is.EqualTo(2));
|
||
Assert.That(stand.Unrecognised, Is.EqualTo(1));
|
||
Assert.That(stand.Added, Is.EqualTo(1));
|
||
}
|
||
|
||
[Test]
|
||
public async Task Die_Suche_grenzt_ein()
|
||
{
|
||
Datei("The Matrix (1999).mkv");
|
||
Datei("The Matrix Reloaded (2003).mkv");
|
||
Datei("Inception (2010).mkv");
|
||
|
||
using var server = new FlaroServer();
|
||
var (client, bibliothek) = await MitBibliothek(server, "Movies");
|
||
await Scannen(client, bibliothek);
|
||
|
||
var treffer = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies?search=matrix");
|
||
Assert.That(treffer!.Total, Is.EqualTo(2));
|
||
}
|
||
|
||
/// <summary>Eine Anfrage darf sich die Seitengrösse nicht selbst aussuchen.</summary>
|
||
[Test]
|
||
public async Task Die_Seitengroesse_ist_gedeckelt()
|
||
{
|
||
for (var i = 1; i <= 5; i++) Datei($"Film {i} (200{i}).mkv");
|
||
|
||
using var server = new FlaroServer();
|
||
var (client, bibliothek) = await MitBibliothek(server, "Movies");
|
||
await Scannen(client, bibliothek);
|
||
|
||
var seite = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies?take=100000");
|
||
Assert.That(seite!.Total, Is.EqualTo(5), "Die Gesamtzahl bleibt richtig");
|
||
Assert.That(seite.Items, Has.Count.LessThanOrEqualTo(200));
|
||
|
||
var zweiteSeite = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies?skip=2&take=2");
|
||
Assert.That(zweiteSeite!.Items, Has.Count.EqualTo(2));
|
||
Assert.That(zweiteSeite.Total, Is.EqualTo(5));
|
||
}
|
||
|
||
[Test]
|
||
public async Task Vor_dem_ersten_Lauf_steht_der_Scan_auf_untaetig()
|
||
{
|
||
using var server = new FlaroServer();
|
||
var (client, bibliothek) = await MitBibliothek(server, "Movies");
|
||
|
||
var stand = await client.GetFromJsonAsync<ScanStatus>($"/api/v1/libraries/{bibliothek}/scan");
|
||
Assert.That(stand!.State, Is.EqualTo("idle"));
|
||
}
|
||
|
||
[Test]
|
||
public async Task Ein_gewoehnlicher_Benutzer_stoesst_keinen_Scan_an()
|
||
{
|
||
using var server = new FlaroServer();
|
||
var (admin, bibliothek) = await MitBibliothek(server, "Movies");
|
||
await admin.PostAsJsonAsync("/api/v1/users",
|
||
new CreateUserRequest("gast", "auch-ein-langes-passwort", false));
|
||
var gast = await server.SignIn("gast", "auch-ein-langes-passwort", "iPad", "g-9");
|
||
|
||
var versuch = await server.AlsAngemeldeter(gast.Token)
|
||
.PostAsync($"/api/v1/libraries/{bibliothek}/scan", null);
|
||
|
||
Assert.That(versuch.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden));
|
||
}
|
||
|
||
[Test]
|
||
public async Task Eine_Bibliothek_die_es_nicht_gibt_laesst_sich_nicht_scannen()
|
||
{
|
||
using var server = new FlaroServer();
|
||
var (client, _) = await MitBibliothek(server, "Movies");
|
||
|
||
var versuch = await client.PostAsync($"/api/v1/libraries/{Guid.CreateVersion7()}/scan", null);
|
||
Assert.That(versuch.StatusCode, Is.EqualTo(HttpStatusCode.NotFound));
|
||
}
|
||
}
|