„Scratch disk" wörtlich zu übersetzen ist derselbe Fehler wie „Wachhund"
für Watchdog und „Keks" für Cookie. Und „Marke" ist kein Fachbegriff für
das, was gemeint ist: Jellyfin nennt diese Abschnitte Media Segments, und
das ist auch das Wort, in dem sich darüber reden lässt.
Kratzplatz* → Transcode* (was es ist, steht in FLARO_TRANSCODE)
Marke, Marken → Segment, Segmente
Markenerkennung → Segmenterkennung
Markenart/-suche → Segmentart/-suche
Tonabdruck → Fingerprint (Chromaprint nennt es so)
Tonabgleich → Fingerprintabgleich
Schlüsselbild → Keyframe
Bildabtaster → Frameabtaster
Gewöhnliches Deutsch bleibt gewöhnliches Deutsch: Wartung, Freigabe,
Meldung, Abspann, Vorspann. Das war nie das Problem.
**Was sich nicht ändert, und zwar mit Absicht.** Die drei
Einstellungsschlüssel `wartung.kratzplatz.paket.*` bleiben, wie sie sind —
sie stehen in der Datenbank, und ein neuer Name hiesse, dass die Werte
des Nutzers stillschweigend auf die Vorgabe zurückfallen. Dasselbe für
die Endpunktpfade `/marken`: sie sind nicht sichtbar, und ein Umbenennen
wäre Diff ohne Gewinn.
**Die Migration ist von Hand geschrieben.** `dotnet ef` hat ein Gerüst
erzeugt, das beide Tabellen löscht und neu anlegt — es sieht eine
verschwundene und eine neue Tabelle, nicht eine umbenannte. Damit wäre
jedes erkannte Segment und jeder Vermerk „hier wurde schon gesucht" weg
gewesen. `RenameTable` und `RenameIndex` tun dasselbe, ohne eine Zeile
anzufassen; nachgemessen an einer echten SQLite-Datei: Zeilen vorher 1
und 1, nachher 1 und 1, Inhalt unverändert.
Nachgezogen: in `bilder.ts` und `main.ts` heisst „marke" ein Häkchen und
das Logo — die blieben unberührt. Und „Segment" ist sächlich, was eine
Blindersetzung nicht weiss: „eine Segment" stand danach an vierzehn
Stellen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
405 lines
16 KiB
C#
405 lines
16 KiB
C#
using System.Net;
|
|
using System.Net.Http.Json;
|
|
using Flaro.Api.Endpoints;
|
|
using Flaro.Core.Library;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace Flaro.Api.Tests;
|
|
|
|
/// <summary>Vorspann und Abspann auf dem Weg zu den Spielern.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <b>Ohne Fingerprintabgleich.</b> Ob er findet, was er finden soll, steht in
|
|
/// <c>FingerprintabgleichTests</c> — mit erfundenen Abdrücken und ohne Mediendateien.
|
|
/// Hier geht es um das Stück danach: dass eine gefundene Segment auch bei dem
|
|
/// ankommt, der den Knopf zeichnen soll.
|
|
/// </remarks>
|
|
public class SegmentTests
|
|
{
|
|
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)
|
|
{
|
|
var voll = Path.Combine(_bestand, relativ);
|
|
Directory.CreateDirectory(Path.GetDirectoryName(voll)!);
|
|
File.WriteAllBytes(voll, new byte[2048]);
|
|
}
|
|
|
|
private async Task<HttpClient> Eingelesen(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>())!;
|
|
|
|
(await client.PostAsync($"/api/v1/libraries/{bibliothek.Id}/scan", null)).EnsureSuccessStatusCode();
|
|
for (var versuch = 0; versuch < 100; versuch++)
|
|
{
|
|
var stand = await client.GetFromJsonAsync<ScanStatus>($"/api/v1/libraries/{bibliothek.Id}/scan");
|
|
if (stand!.State is "finished") return client;
|
|
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!;
|
|
}
|
|
|
|
private static async Task<(Guid Serie, Guid Folge)> MitEinerFolge(HttpClient client)
|
|
{
|
|
var serien = await client.GetFromJsonAsync<Page<SeriesResponse>>("/api/v1/series");
|
|
var serie = serien!.Items[0].Id;
|
|
var folgen = await client.GetFromJsonAsync<Page<EpisodeResponse>>(
|
|
$"/api/v1/series/{serie}/episodes");
|
|
return (serie, folgen!.Items[0].Id);
|
|
}
|
|
|
|
[Test]
|
|
public async Task Eine_Segment_kommt_bei_der_Folge_an()
|
|
{
|
|
Datei("Dark/Season 01/Dark - S01E01 - Geheimnisse.mkv");
|
|
|
|
using var server = new FlaroServer();
|
|
var client = await Eingelesen(server, "Shows");
|
|
var (_, folge) = await MitEinerFolge(client);
|
|
|
|
await server.InDerDatenbank(async db =>
|
|
{
|
|
db.Segmente.Add(new Segment
|
|
{
|
|
EpisodeId = folge, Art = Segmentart.Intro,
|
|
VonSekunden = 30, BisSekunden = 65, ErkanntAm = DateTimeOffset.UtcNow,
|
|
});
|
|
db.Segmente.Add(new Segment
|
|
{
|
|
EpisodeId = folge, Art = Segmentart.Abspann,
|
|
VonSekunden = 2500, BisSekunden = 2600, ErkanntAm = DateTimeOffset.UtcNow,
|
|
});
|
|
await db.SaveChangesAsync();
|
|
});
|
|
|
|
var einzeln = await client.GetFromJsonAsync<EpisodeResponse>($"/api/v1/episodes/{folge}");
|
|
var intro = einzeln!.Markers!.Single(m => m.Kind == "intro");
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(einzeln.Markers, Has.Count.EqualTo(2));
|
|
Assert.That(intro.StartSeconds, Is.EqualTo(30));
|
|
Assert.That(intro.EndSeconds, Is.EqualTo(65));
|
|
Assert.That(einzeln.Markers!.Any(m => m.Kind == "credits"), Is.True,
|
|
"der Abspann heisst gegenüber den Apps credits");
|
|
});
|
|
}
|
|
|
|
/// <summary>Auch in der Folgenliste — dort holt die App sie beim Blättern.</summary>
|
|
[Test]
|
|
public async Task Die_Folgenliste_bringt_die_Segmente_mit()
|
|
{
|
|
Datei("Dark/Season 01/Dark - S01E01 - Geheimnisse.mkv");
|
|
Datei("Dark/Season 01/Dark - S01E02 - Luegen.mkv");
|
|
|
|
using var server = new FlaroServer();
|
|
var client = await Eingelesen(server, "Shows");
|
|
var (serie, folge) = await MitEinerFolge(client);
|
|
|
|
await server.InDerDatenbank(async db =>
|
|
{
|
|
db.Segmente.Add(new Segment
|
|
{
|
|
EpisodeId = folge, Art = Segmentart.Intro,
|
|
VonSekunden = 10, BisSekunden = 40, ErkanntAm = DateTimeOffset.UtcNow,
|
|
});
|
|
await db.SaveChangesAsync();
|
|
});
|
|
|
|
var liste = await client.GetFromJsonAsync<Page<EpisodeResponse>>(
|
|
$"/api/v1/series/{serie}/episodes");
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(liste!.Items.Single(f => f.Id == folge).Markers, Has.Count.EqualTo(1));
|
|
Assert.That(liste.Items.Single(f => f.Id != folge).Markers, Is.Empty,
|
|
"eine Folge ohne Segment bekommt eine leere Liste, nicht die der anderen");
|
|
});
|
|
}
|
|
|
|
/// <summary>Ohne erkannte Segmente bleibt die Liste leer — kein Knopf, kein Fehler.</summary>
|
|
[Test]
|
|
public async Task Ohne_Erkennung_ist_die_Liste_leer()
|
|
{
|
|
Datei("Dark/Season 01/Dark - S01E01 - Geheimnisse.mkv");
|
|
|
|
using var server = new FlaroServer();
|
|
var client = await Eingelesen(server, "Shows");
|
|
var (_, folge) = await MitEinerFolge(client);
|
|
|
|
var einzeln = await client.GetFromJsonAsync<EpisodeResponse>($"/api/v1/episodes/{folge}");
|
|
Assert.That(einzeln!.Markers, Is.Empty);
|
|
}
|
|
|
|
/// <summary>Den Lauf darf nur ein Verwalter anstossen.</summary>
|
|
[Test]
|
|
public async Task Die_Suche_laesst_sich_anstossen()
|
|
{
|
|
Datei("Dark/Season 01/Dark - S01E01 - Geheimnisse.mkv");
|
|
|
|
using var server = new FlaroServer();
|
|
var client = await Eingelesen(server, "Shows");
|
|
var serien = await client.GetFromJsonAsync<Page<SeriesResponse>>("/api/v1/series");
|
|
|
|
var antwort = await client.PostAsync($"/api/v1/series/{serien!.Items[0].Id}/marken", null);
|
|
var unbekannt = await client.PostAsync($"/api/v1/series/{Guid.NewGuid()}/marken", null);
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.Accepted));
|
|
Assert.That(unbekannt.StatusCode, Is.EqualTo(HttpStatusCode.NotFound));
|
|
});
|
|
}
|
|
|
|
/// <summary>Eine abgesuchte Staffel wird nicht noch einmal abgetastet.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <b>Der teuerste Fehler, den es hier gab.</b> Gemerkt wurde nur, was
|
|
/// gefunden wurde. Eine Staffel ohne Abspann — es gibt genug davon —
|
|
/// sah damit bei jedem Scan aus wie eine, in der noch niemand
|
|
/// nachgesehen hat: jede Tonspur jeder Folge noch einmal von der
|
|
/// Platte, für immer, ohne Ertrag.
|
|
///
|
|
/// Geprüft wird hier der Vermerk, nicht das Abtasten selbst — ohne
|
|
/// fpcalc und ohne echten Ton gibt es nichts zu finden, und genau
|
|
/// dieser Fall ist der, um den es geht.
|
|
/// </remarks>
|
|
[Test]
|
|
public async Task Ein_Lauf_ohne_Fund_merkt_sich_das()
|
|
{
|
|
Datei("Dark/Season 01/Dark - S01E01 - Geheimnisse.mkv");
|
|
Datei("Dark/Season 01/Dark - S01E02 - Luegen.mkv");
|
|
|
|
using var server = new FlaroServer();
|
|
var client = await Eingelesen(server, "Shows");
|
|
var serien = await client.GetFromJsonAsync<Page<SeriesResponse>>("/api/v1/series");
|
|
var serie = serien!.Items[0].Id;
|
|
|
|
(await client.PostAsync($"/api/v1/series/{serie}/marken", null)).EnsureSuccessStatusCode();
|
|
|
|
// Der Lauf hängt im Hintergrund; ohne fpcalc ist er sofort durch.
|
|
List<Segmentsuche> vermerke = [];
|
|
for (var versuch = 0; versuch < 40; versuch++)
|
|
{
|
|
await server.InDerDatenbank(async db =>
|
|
vermerke = await db.Segmentsuchen.AsNoTracking()
|
|
.Where(x => x.SeriesId == serie).ToListAsync());
|
|
if (vermerke.Count > 0) break;
|
|
await Task.Delay(50);
|
|
}
|
|
|
|
// Ohne fpcalc gibt es keinen Vermerk — dann ist nichts gelaufen und
|
|
// auch nichts zu merken. Mit fpcalc müssen beide Arten dastehen.
|
|
Assert.That(vermerke.Count, Is.AnyOf(0, 2),
|
|
"entweder gar nicht gelaufen oder beide Arten vermerkt");
|
|
if (vermerke.Count == 2)
|
|
{
|
|
Assert.That(vermerke.Select(v => v.Folgen), Is.All.EqualTo(2),
|
|
"die Folgenzahl löst den nächsten Lauf aus");
|
|
}
|
|
}
|
|
|
|
/// <summary>Ein Film trägt seine Abspannsegment genauso.</summary>
|
|
[Test]
|
|
public async Task Ein_Film_bekommt_seine_Segment()
|
|
{
|
|
Datei("Matrix (1999)/Matrix (1999).mkv");
|
|
|
|
using var server = new FlaroServer();
|
|
var client = await Eingelesen(server, "Movies");
|
|
var filme = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies");
|
|
var film = filme!.Items[0].Id;
|
|
|
|
await server.InDerDatenbank(async db =>
|
|
{
|
|
db.Segmente.Add(new Segment
|
|
{
|
|
MovieId = film, Art = Segmentart.Abspann,
|
|
VonSekunden = 6900, BisSekunden = 7200, ErkanntAm = DateTimeOffset.UtcNow,
|
|
});
|
|
await db.SaveChangesAsync();
|
|
});
|
|
|
|
var einzeln = await client.GetFromJsonAsync<MovieResponse>($"/api/v1/movies/{film}");
|
|
var liste = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies");
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(einzeln!.Markers!.Single().Kind, Is.EqualTo("credits"));
|
|
Assert.That(einzeln.Markers!.Single().StartSeconds, Is.EqualTo(6900));
|
|
Assert.That(liste!.Items[0].Markers, Has.Count.EqualTo(1), "auch in der Liste");
|
|
});
|
|
}
|
|
|
|
/// <summary>Eine falsches Segment lässt sich wieder wegnehmen.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// Ohne diesen Weg wäre die Erkennung eine Blackbox, die manchmal recht
|
|
/// hat: ein Fehlgriff bliebe stehen, und niemand könnte etwas dagegen
|
|
/// tun ausser die Datenbank anzufassen.
|
|
/// </remarks>
|
|
[Test]
|
|
public async Task Eine_falsche_Filmsegment_laesst_sich_verwerfen()
|
|
{
|
|
Datei("Matrix (1999)/Matrix (1999).mkv");
|
|
|
|
using var server = new FlaroServer();
|
|
var client = await Eingelesen(server, "Movies");
|
|
var filme = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies");
|
|
var film = filme!.Items[0].Id;
|
|
|
|
await server.InDerDatenbank(async db =>
|
|
{
|
|
db.Segmente.Add(new Segment
|
|
{
|
|
MovieId = film, Art = Segmentart.Abspann,
|
|
VonSekunden = 60, BisSekunden = 7200, ErkanntAm = DateTimeOffset.UtcNow,
|
|
});
|
|
db.Segmentsuchen.Add(new Segmentsuche
|
|
{
|
|
MovieId = film, Art = Segmentart.Abspann, Folgen = 1,
|
|
GelaufenAm = DateTimeOffset.UtcNow,
|
|
});
|
|
await db.SaveChangesAsync();
|
|
});
|
|
|
|
var weg = await client.DeleteAsync($"/api/v1/movies/{film}/marken");
|
|
var danach = await client.GetFromJsonAsync<MovieResponse>($"/api/v1/movies/{film}");
|
|
|
|
var vermerke = 0;
|
|
await server.InDerDatenbank(async db =>
|
|
vermerke = await db.Segmentsuchen.CountAsync(x => x.MovieId == film));
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(weg.StatusCode, Is.EqualTo(HttpStatusCode.NoContent));
|
|
Assert.That(danach!.Markers, Is.Empty);
|
|
Assert.That(vermerke, Is.EqualTo(1),
|
|
"der Vermerk bleibt, sonst legte der nächste Scan dieselbe Segment wieder an");
|
|
});
|
|
}
|
|
|
|
/// <summary>Wie weit ein Lauf greift — drei Umfänge, drei Antworten.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>„Schon durchsucht" und „hat ein Segment" sind zwei verschiedene
|
|
/// Dinge.</b> Ein Film, bei dem die Suche einmal nichts fand, trägt
|
|
/// einen Vermerk und kein Segment — und war damit für jeden weiteren
|
|
/// Lauf erledigt, obwohl er der interessanteste Fall ist.
|
|
/// </para>
|
|
/// <para>
|
|
/// Geprüft wird am Vermerk: fasst der Lauf den Film an, schreibt er
|
|
/// <c>GelaufenAm</c> neu. Das ist die einzige Spur, die auch ohne
|
|
/// ffmpeg und ohne echten Abspann entsteht.
|
|
/// </para>
|
|
/// </remarks>
|
|
[TestCase("Neue", false, TestName = "Neue überspringt, was schon durchsucht ist")]
|
|
[TestCase("Luecken", true, TestName = "Luecken nimmt sich den Film ohne Segment vor")]
|
|
[TestCase("Alles", true, TestName = "Alles nimmt sich alles vor")]
|
|
public async Task Der_Umfang_entscheidet_bei_einem_Film_ohne_Segment(
|
|
string umfang, bool erwartetAngefasst)
|
|
{
|
|
Datei("Matrix (1999)/Matrix (1999).mkv");
|
|
|
|
using var server = new FlaroServer();
|
|
var client = await Eingelesen(server, "Movies");
|
|
var filme = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies");
|
|
var film = filme!.Items[0].Id;
|
|
|
|
// Gesucht wurde schon, gefunden nichts: Vermerk ja, Segment nein.
|
|
var vorher = new DateTimeOffset(2020, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
|
await server.InDerDatenbank(async db =>
|
|
{
|
|
// **Ohne Laufzeit kommt der Lauf nicht bis zum Vermerk.** Die
|
|
// Prüfdatei ist leer; die Laufzeit steht sonst aus ffprobe.
|
|
var datei = await db.MediaFiles.FirstAsync(d => d.MovieId == film);
|
|
datei.Duration = TimeSpan.FromMinutes(120);
|
|
|
|
db.Segmentsuchen.Add(new Segmentsuche
|
|
{
|
|
MovieId = film, Art = Segmentart.Abspann, Folgen = 1, GelaufenAm = vorher,
|
|
});
|
|
await db.SaveChangesAsync();
|
|
});
|
|
|
|
(await client.PostAsync($"/api/v1/movies/{film}/marken?umfang={umfang}", null))
|
|
.EnsureSuccessStatusCode();
|
|
|
|
var gelaufen = vorher;
|
|
for (var versuch = 0; versuch < 40; versuch++)
|
|
{
|
|
await server.InDerDatenbank(async db =>
|
|
gelaufen = await db.Segmentsuchen.AsNoTracking()
|
|
.Where(x => x.MovieId == film).Select(x => x.GelaufenAm).FirstAsync());
|
|
if (gelaufen != vorher) break;
|
|
await Task.Delay(50);
|
|
}
|
|
|
|
Assert.That(gelaufen != vorher, Is.EqualTo(erwartetAngefasst),
|
|
erwartetAngefasst
|
|
? "dieser Umfang muss den Film noch einmal anfassen"
|
|
: "dieser Umfang muss ihn überspringen");
|
|
}
|
|
|
|
/// <summary>Ein Film mit Segment bleibt bei „nur Lücken" unangetastet.</summary>
|
|
[Test]
|
|
public async Task Luecken_laesst_einen_Film_mit_Segment_in_Ruhe()
|
|
{
|
|
Datei("Matrix (1999)/Matrix (1999).mkv");
|
|
|
|
using var server = new FlaroServer();
|
|
var client = await Eingelesen(server, "Movies");
|
|
var filme = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies");
|
|
var film = filme!.Items[0].Id;
|
|
|
|
var vorher = new DateTimeOffset(2020, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
|
await server.InDerDatenbank(async db =>
|
|
{
|
|
var datei = await db.MediaFiles.FirstAsync(d => d.MovieId == film);
|
|
datei.Duration = TimeSpan.FromMinutes(120);
|
|
|
|
db.Segmente.Add(new Segment
|
|
{
|
|
MovieId = film, Art = Segmentart.Abspann,
|
|
VonSekunden = 6900, BisSekunden = 7200, ErkanntAm = vorher,
|
|
});
|
|
db.Segmentsuchen.Add(new Segmentsuche
|
|
{
|
|
MovieId = film, Art = Segmentart.Abspann, Folgen = 1, GelaufenAm = vorher,
|
|
});
|
|
await db.SaveChangesAsync();
|
|
});
|
|
|
|
(await client.PostAsync($"/api/v1/movies/{film}/marken?umfang=Luecken", null))
|
|
.EnsureSuccessStatusCode();
|
|
await Task.Delay(400);
|
|
|
|
var gelaufen = DateTimeOffset.MinValue;
|
|
await server.InDerDatenbank(async db =>
|
|
gelaufen = await db.Segmentsuchen.AsNoTracking()
|
|
.Where(x => x.MovieId == film).Select(x => x.GelaufenAm).FirstAsync());
|
|
|
|
Assert.That(gelaufen, Is.EqualTo(vorher),
|
|
"wo schon ein Segment steht, ist nichts zu füllen");
|
|
}
|
|
}
|