using System.Diagnostics;
using System.Net;
using System.Net.Http.Json;
using Flaro.Api.Endpoints;
using Flaro.Core.Library;
using Flaro.Core.Metadata;
using Flaro.Media;
using Microsoft.Extensions.DependencyInjection;
namespace Flaro.Api.Tests;
/// Eine falsche Zuordnung richtigstellen — und sie festschreiben.
public class MatchCorrectionTests
{
private string _bestand = "";
private string _filmOrdner = "";
private FlaroServer? _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(), "flaro-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) { }
}
/// Ein Anbieter, der zwei verschiedene Filme kennt.
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");
/// Ein Treffer mit anderem Jahr als im Dateinamen.
public static readonly MovieMetadata AnderesJahr =
new(613, "Der Untergang", 2004, "Berlin, April 1945.", null, null, "tt0363163");
public Task IsConfiguredAsync(CancellationToken ct = default) => Task.FromResult(true);
// Die Suche liefert den beliebteren zuerst — genau wie TMDB.
public Task FindMovieAsync(string t, int? j, CancellationToken ct = default) =>
Task.FromResult(Falsch);
/// Womit zuletzt gesucht wurde — die Prüfungen unten lesen es.
public static int? ZuletztesJahr { get; private set; }
public static bool Gefragt { get; private set; }
public Task> SearchMoviesAsync(string t, int? j, CancellationToken ct = default)
{
ZuletztesJahr = j;
Gefragt = true;
return Task.FromResult>([Falsch, Richtig]);
}
public static void Vergessen() { ZuletztesJahr = null; Gefragt = false; }
public Task GetMovieAsync(int id, CancellationToken ct = default) =>
Task.FromResult(
id == Richtig.ProviderId ? Richtig
: id == Falsch.ProviderId ? Falsch
: id == AnderesJahr.ProviderId ? AnderesJahr
: null);
public Task FindSeriesAsync(string t, int? j, CancellationToken ct = default) =>
Task.FromResult(null);
public Task> SearchSeriesAsync(string t, int? j, CancellationToken ct = default) =>
Task.FromResult>([]);
public Task GetSeriesAsync(int id, CancellationToken ct = default) =>
Task.FromResult(null);
public Task FindEpisodeAsync(int s, int st, int f, CancellationToken ct = default) =>
Task.FromResult(null);
}
private async Task<(HttpClient Client, MovieResponse Film)> Eingelesen(bool beiwerkSchreiben)
{
_server = new FlaroServer(dienste =>
{
dienste.AddScoped();
dienste.AddSingleton(
new SidecarWriter(new SidecarOptions(beiwerkSchreiben),
Microsoft.Extensions.Logging.Abstractions.NullLogger.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())!;
(await client.PostAsync($"/api/v1/libraries/{bibliothek.Id}/scan", null)).EnsureSuccessStatusCode();
for (var i = 0; i < 150; i++)
{
var stand = await client.GetFromJsonAsync($"/api/v1/libraries/{bibliothek.Id}/scan");
if (stand!.State == "finished") break;
await Task.Delay(50);
}
var filme = await client.GetFromJsonAsync>("/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>($"/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"));
}
/// Ein leeres Jahrfeld heisst „ich weiss es nicht", nicht „nimm das vom Dateinamen".
///
///
/// Der Anlass. „Plan B (2010)" heisst bei TMDB „DivertiMento –
/// Plan B" und ist von 2011. Wer das Jahr aus der Maske herausnahm, um
/// ohne es zu suchen, bekam trotzdem keinen Treffer: der Server reichte
/// das Jahr aus dem Dateinamen nach. Das leere Feld hatte damit gar
/// keine Wirkung, und ein falsch benannter Film war von Hand nicht zu
/// finden.
///
[Test]
public async Task Ohne_Jahr_wird_wirklich_ohne_Jahr_gesucht()
{
var (client, film) = await Eingelesen(beiwerkSchreiben: false);
ZweiTreffer.Vergessen();
await client.GetFromJsonAsync>(
$"/api/v1/movies/{film.Id}/matches?query=Plan%20B");
Assert.Multiple(() =>
{
Assert.That(ZweiTreffer.Gefragt, Is.True, "die Suche muss überhaupt stattgefunden haben");
Assert.That(ZweiTreffer.ZuletztesJahr, Is.Null,
"das Jahr aus dem Dateinamen darf nicht nachgereicht werden");
});
}
/// Ein angegebenes Jahr geht unverändert weiter.
[Test]
public async Task Ein_angegebenes_Jahr_kommt_an()
{
var (client, film) = await Eingelesen(beiwerkSchreiben: false);
ZweiTreffer.Vergessen();
await client.GetFromJsonAsync>(
$"/api/v1/movies/{film.Id}/matches?query=Plan%20B&year=2011");
Assert.That(ZweiTreffer.ZuletztesJahr, Is.EqualTo(2011));
}
[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();
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>("/api/v1/movies");
Assert.That(danach!.Items.Single().Title, Is.EqualTo("Dark"));
Assert.Multiple(() =>
{
Assert.That(stand.Id, Is.EqualTo(film.Id), "ohne Zusammenlegen bleibt es derselbe Eintrag");
Assert.That(stand.Merged, Is.False);
});
}
/// Wird beim Korrigieren zusammengelegt, sagt die Antwort es auch.
///
///
///
/// Sonst zeigt die Mediathek weiter auf einen gelöschten Eintrag. Der
/// korrigierte Film verschwindet beim Zusammenlegen — die Kachel blieb aber
/// stehen, und wer sie dann bearbeiten wollte, bekam eine 404. Angezeigt
/// wurde dafür „Not Found": englisch, und für etwas, das gar kein Fehler
/// ist.
///
///
/// Der Ablauf ist der aus dem Betrieb, nicht der kürzeste: zwei
/// Ordner gleichzeitig legt schon der Scan zusammen. Hier kommt der zweite
/// dazu, nachdem der erste korrigiert wurde — dann stehen zwei
/// Einträge mit verschiedenen Kennungen da, und die Korrektur des zweiten
/// führt sie zusammen.
///
///
[Test]
public async Task Ein_Zusammenlegen_beim_Korrigieren_wird_gemeldet()
{
var (client, film) = await Eingelesen(beiwerkSchreiben: false);
// Der erste bekommt die richtige Kennung.
(await client.PutAsJsonAsync($"/api/v1/movies/{film.Id}/match", new ApplyMatchRequest(70523)))
.EnsureSuccessStatusCode();
// Jetzt kommt ein zweiter Ordner mit demselben Film dazu.
Zweitordner("Dunkel (2017)");
var bibliotheken = await client.GetFromJsonAsync>("/api/v1/libraries");
await Scannen(client, bibliotheken![0].Id);
var filme = await client.GetFromJsonAsync>("/api/v1/movies");
Assert.That(filme!.Items, Has.Count.EqualTo(2),
"verschiedene Kennungen — der Scan legt sie nicht von selbst zusammen");
var zweiter = filme.Items.Single(m => m.Id != film.Id);
var antwort = await client.PutAsJsonAsync(
$"/api/v1/movies/{zweiter.Id}/match", new ApplyMatchRequest(70523));
antwort.EnsureSuccessStatusCode();
var stand = (await antwort.Content.ReadFromJsonAsync())!;
var weg = await client.GetAsync($"/api/v1/movies/{zweiter.Id}");
Assert.Multiple(() =>
{
Assert.That(stand.Merged, Is.True);
Assert.That(stand.Id, Is.EqualTo(film.Id),
"der überlebende Eintrag — genau das muss der Client erfahren");
Assert.That(weg.StatusCode, Is.EqualTo(HttpStatusCode.NotFound),
"und der korrigierte ist wirklich weg");
});
}
/// Legt einen weiteren Filmordner im Prüfbestand an.
private void Zweitordner(string name)
{
var ordner = Directory.CreateDirectory(Path.Combine(_bestand, name)).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(ordner, $"{name}.mkv"),
})
{
start.ArgumentList.Add(a);
}
using var prozess = Process.Start(start)!;
prozess.StandardError.ReadToEnd();
prozess.WaitForExit();
}
private static async Task Scannen(HttpClient client, Guid bibliothek)
{
(await client.PostAsync($"/api/v1/libraries/{bibliothek}/scan", null)).EnsureSuccessStatusCode();
for (var i = 0; i < 200; i++)
{
var stand = await client.GetFromJsonAsync($"/api/v1/libraries/{bibliothek}/scan");
if (stand!.State == "finished") return;
await Task.Delay(50);
}
Assert.Fail("Der Scan wurde nicht fertig.");
}
/* ---------- Sortiertitel ---------- */
/// Der Sortiertitel bestimmt die Reihenfolge, der Titel bleibt stehen.
///
///
/// Wofür man ihn braucht: eine Reihe steht alphabetisch
/// durcheinander. „…Orden des Phönix" kommt vor „…Stein der Weisen", weil
/// O vor S liegt — obwohl der Stein der erste Teil ist.
///
[Test]
public async Task Ein_Sortiertitel_bestimmt_die_Reihenfolge()
{
var (client, film) = await Eingelesen(beiwerkSchreiben: false);
(await client.PutAsJsonAsync($"/api/v1/movies/{film.Id}/sorttitle",
new { title = "Zzz zuletzt" })).EnsureSuccessStatusCode();
var danach = (await client.GetFromJsonAsync($"/api/v1/movies/{film.Id}"))!;
Assert.Multiple(() =>
{
Assert.That(danach.SortTitle, Is.EqualTo("Zzz zuletzt"));
Assert.That(danach.SortTitlePinned, Is.True);
Assert.That(danach.Title, Is.EqualTo(film.Title), "der angezeigte Titel bleibt, wie er war");
});
}
/// Ein Sortiertitel von Hand überlebt das Auffrischen.
///
///
/// Sonst tippt man ihn nach jedem Lauf neu — genau wie beim Titel
/// und bei den Bildern. Beides hängt aber an eigenen Merkern: wer die
/// Reihenfolge festlegt, will den Titel trotzdem weiter automatisch
/// bekommen.
///
[Test]
public async Task Ein_Sortiertitel_von_Hand_ueberlebt_eine_Korrektur()
{
var (client, film) = await Eingelesen(beiwerkSchreiben: false);
(await client.PutAsJsonAsync($"/api/v1/movies/{film.Id}/sorttitle",
new { title = "Harry Potter 3" })).EnsureSuccessStatusCode();
// Eine Korrektur schreibt Titel und Sortiertitel neu — den gesetzten nicht.
(await client.PutAsJsonAsync($"/api/v1/movies/{film.Id}/match", new ApplyMatchRequest(70523)))
.EnsureSuccessStatusCode();
var danach = (await client.GetFromJsonAsync($"/api/v1/movies/{film.Id}"))!;
Assert.Multiple(() =>
{
Assert.That(danach.Title, Is.EqualTo("Dark"), "der Titel kommt aus der Quelle");
Assert.That(danach.SortTitle, Is.EqualTo("Harry Potter 3"), "die Reihenfolge bleibt von Hand");
});
}
/// Und auch eine Titeländerung lässt ihn stehen.
[Test]
public async Task Eine_Titelaenderung_laesst_den_Sortiertitel_stehen()
{
var (client, film) = await Eingelesen(beiwerkSchreiben: false);
(await client.PutAsJsonAsync($"/api/v1/movies/{film.Id}/sorttitle",
new { title = "Harry Potter 3" })).EnsureSuccessStatusCode();
(await client.PutAsJsonAsync($"/api/v1/movies/{film.Id}/title",
new { title = "Ganz anderer Name" })).EnsureSuccessStatusCode();
var danach = (await client.GetFromJsonAsync($"/api/v1/movies/{film.Id}"))!;
Assert.Multiple(() =>
{
Assert.That(danach.Title, Is.EqualTo("Ganz anderer Name"));
Assert.That(danach.SortTitle, Is.EqualTo("Harry Potter 3"));
});
}
/// „Wieder automatisch" bildet ihn sofort neu — nicht erst beim nächsten Lauf.
///
///
/// Anders als beim Titel, und mit Absicht. Der Titel steht sichtbar
/// da; ihn bis zum Auffrischen stehenzulassen ist besser als eine Lücke.
/// Der Sortiertitel steht nirgends — bliebe er falsch, stünde die Liste
/// solange in der falschen Reihenfolge, und niemand sähe warum.
///
[Test]
public async Task Wieder_automatisch_bildet_den_Sortiertitel_neu()
{
var (client, film) = await Eingelesen(beiwerkSchreiben: false);
(await client.PutAsJsonAsync($"/api/v1/movies/{film.Id}/title",
new { title = "Die Brücke" })).EnsureSuccessStatusCode();
(await client.PutAsJsonAsync($"/api/v1/movies/{film.Id}/sorttitle",
new { title = "Zzz" })).EnsureSuccessStatusCode();
(await client.DeleteAsync($"/api/v1/movies/{film.Id}/sorttitle")).EnsureSuccessStatusCode();
var danach = (await client.GetFromJsonAsync($"/api/v1/movies/{film.Id}"))!;
Assert.Multiple(() =>
{
Assert.That(danach.SortTitle, Is.EqualTo("Brücke"), "der Artikel fällt wieder vorn weg");
Assert.That(danach.SortTitlePinned, Is.False);
});
}
[Test]
public async Task Ein_leerer_Sortiertitel_wird_abgelehnt()
{
var (client, film) = await Eingelesen(beiwerkSchreiben: false);
var antwort = await client.PutAsJsonAsync(
$"/api/v1/movies/{film.Id}/sorttitle", new { title = " " });
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
}
[Test]
public async Task Nur_Administratoren_duerfen_den_Sortiertitel_setzen()
{
var (client, film) = await Eingelesen(beiwerkSchreiben: false);
(await client.PostAsJsonAsync("/api/v1/users",
new CreateUserRequest("gast", "ein-langes-passwort", false))).EnsureSuccessStatusCode();
var gast = _server!.AlsAngemeldeter(
(await _server.SignIn("gast", "ein-langes-passwort", "iPad", "geraet-2")).Token);
var antwort = await gast.PutAsJsonAsync(
$"/api/v1/movies/{film.Id}/sorttitle", new { title = "Egal" });
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden));
}
/// Die Korrektur überlebt einen Neuaufbau der Datenbank.
///
///
/// Darum geht es bei der NFO-Datei. In der Datenbank allein wäre
/// die Entscheidung mit dem nächsten Umzug oder Neuaufbau verloren.
///
[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("Dark"));
}
[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));
}
/// Eine Korrektur muss den nächsten Scan überstehen.
///
///
/// Live gefunden, und der Fehler war unangenehm. 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
/// ParsedYear, das sich nie ändert.
///
[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 bibliothek = (await client.GetFromJsonAsync>("/api/v1/libraries"))!.Single();
(await client.PostAsync($"/api/v1/libraries/{bibliothek.Id}/scan", null))
.EnsureSuccessStatusCode();
for (var i = 0; i < 150; i++)
{
var stand = await client.GetFromJsonAsync(
$"/api/v1/libraries/{bibliothek.Id}/scan");
if (stand!.State == "finished") break;
await Task.Delay(50);
}
var danach = await client.GetFromJsonAsync>("/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));
}
}