„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>
328 lines
11 KiB
C#
328 lines
11 KiB
C#
using Flaro.Core.Playback;
|
|
|
|
namespace Flaro.Core.Tests;
|
|
|
|
/// <summary>Die Regel, um derentwillen dieser Server existiert.</summary>
|
|
[TestFixture]
|
|
public sealed class ProgressRuleTests
|
|
{
|
|
private static readonly TimeSpan Zweistünder = TimeSpan.FromHours(2);
|
|
|
|
/// <summary>Die Vorgabe des Servers — was gilt, wenn niemand etwas einstellt.</summary>
|
|
private static readonly Wiedergaberegeln Regeln = Wiedergaberegeln.Vorgabe;
|
|
|
|
/// <summary>Kurzschreibweise, damit die Prüfungen lesbar bleiben.</summary>
|
|
private static bool BelongsInContinueWatching(UserItemState zustand) =>
|
|
ProgressRule.BelongsInContinueWatching(zustand, Regeln);
|
|
|
|
/// <summary>Der Fall, der den Ausschlag gab.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// Jellyfins <c>MinResumePct</c> steht auf 5. Bei zwei Stunden sind das
|
|
/// sechs Minuten, und wer nach fünf abbricht, findet beim nächsten Mal
|
|
/// nichts vor. Hier ist genau diese Stelle abgelegt.
|
|
/// </remarks>
|
|
[Test]
|
|
public void Die_ersten_Minuten_zaehlen_auch()
|
|
{
|
|
var ergebnis = ProgressRule.Apply(TimeSpan.FromMinutes(5), Zweistünder, Regeln);
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(ergebnis.Position, Is.EqualTo(TimeSpan.FromMinutes(5)));
|
|
Assert.That(ergebnis.Watched, Is.False);
|
|
});
|
|
}
|
|
|
|
[Test]
|
|
public void Eine_einzige_Sekunde_zaehlt_auch()
|
|
{
|
|
var ergebnis = ProgressRule.Apply(TimeSpan.FromSeconds(1), Zweistünder, Regeln);
|
|
|
|
Assert.That(ergebnis.Position, Is.EqualTo(TimeSpan.FromSeconds(1)));
|
|
}
|
|
|
|
/// <summary>Jellyfins <c>MinResumeDurationSeconds</c> steht auf 300.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// Dort gilt eine vierminütige Folge nach dreissig Sekunden als
|
|
/// vollständig gesehen. Hier ist sie das, was sie ist: angefangen.
|
|
/// </remarks>
|
|
[Test]
|
|
public void Ein_kurzer_Titel_gilt_nicht_sofort_als_gesehen()
|
|
{
|
|
var ergebnis = ProgressRule.Apply(TimeSpan.FromSeconds(30), TimeSpan.FromMinutes(4), Regeln);
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(ergebnis.Watched, Is.False);
|
|
Assert.That(ergebnis.Position, Is.EqualTo(TimeSpan.FromSeconds(30)));
|
|
});
|
|
}
|
|
|
|
[Test]
|
|
public void Kurz_vor_Schluss_gilt_er_als_gesehen()
|
|
{
|
|
var ergebnis = ProgressRule.Apply(TimeSpan.FromMinutes(115), Zweistünder, Regeln);
|
|
|
|
Assert.That(ergebnis.Watched, Is.True);
|
|
}
|
|
|
|
/// <summary>Und die Stelle bleibt trotzdem stehen.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// Jellyfin setzt sie an dieser Stelle auf null. Wer den Abspann sehen
|
|
/// oder die letzte Szene nochmal will, fängt dort von vorn an.
|
|
/// </remarks>
|
|
[Test]
|
|
public void Gesehen_wirft_die_Stelle_nicht_weg()
|
|
{
|
|
var ergebnis = ProgressRule.Apply(TimeSpan.FromMinutes(118), Zweistünder, Regeln);
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(ergebnis.Watched, Is.True);
|
|
Assert.That(ergebnis.Position, Is.EqualTo(TimeSpan.FromMinutes(118)));
|
|
});
|
|
}
|
|
|
|
[Test]
|
|
public void Genau_an_der_Grenze_gilt_er_als_gesehen()
|
|
{
|
|
var ergebnis = ProgressRule.Apply(Zweistünder * Regeln.GesehenAnteil, Zweistünder, Regeln);
|
|
|
|
Assert.That(ergebnis.Watched, Is.True);
|
|
}
|
|
|
|
[Test]
|
|
public void Eine_Sekunde_davor_noch_nicht()
|
|
{
|
|
var ergebnis = ProgressRule.Apply(
|
|
Zweistünder * Regeln.GesehenAnteil - TimeSpan.FromSeconds(1), Zweistünder, Regeln);
|
|
|
|
Assert.That(ergebnis.Watched, Is.False);
|
|
}
|
|
|
|
/// <summary>Ohne Laufzeit gibt es keinen Anteil — also kein Urteil.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// Jellyfin nimmt in diesem Fall an, der Titel sei vollständig gesehen
|
|
/// („we'll just have to assume it was fully played"). Das ist die
|
|
/// Annahme, die man nicht zurücknehmen kann, ohne sie zu bemerken.
|
|
/// </remarks>
|
|
[Test]
|
|
public void Ohne_Laufzeit_wird_nichts_vermutet()
|
|
{
|
|
var ergebnis = ProgressRule.Apply(TimeSpan.FromMinutes(90), null, Regeln);
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(ergebnis.Watched, Is.False);
|
|
Assert.That(ergebnis.Position, Is.EqualTo(TimeSpan.FromMinutes(90)));
|
|
});
|
|
}
|
|
|
|
[Test]
|
|
public void Eine_Laufzeit_von_null_ist_keine()
|
|
{
|
|
var ergebnis = ProgressRule.Apply(TimeSpan.FromMinutes(3), TimeSpan.Zero, Regeln);
|
|
|
|
Assert.That(ergebnis.Watched, Is.False);
|
|
}
|
|
|
|
/// <summary>Spieler melden beim Spulen und beim Beenden Unsinn.</summary>
|
|
[Test]
|
|
public void Eine_negative_Stelle_wird_zu_null()
|
|
{
|
|
var ergebnis = ProgressRule.Apply(TimeSpan.FromSeconds(-5), Zweistünder, Regeln);
|
|
|
|
Assert.That(ergebnis.Position, Is.EqualTo(TimeSpan.Zero));
|
|
}
|
|
|
|
[Test]
|
|
public void Jenseits_des_Endes_wird_auf_das_Ende_gekuerzt()
|
|
{
|
|
var ergebnis = ProgressRule.Apply(Zweistünder + TimeSpan.FromSeconds(3), Zweistünder, Regeln);
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(ergebnis.Position, Is.EqualTo(Zweistünder));
|
|
Assert.That(ergebnis.Watched, Is.True);
|
|
});
|
|
}
|
|
|
|
// --- Weiterschauen ---------------------------------------------------
|
|
|
|
[Test]
|
|
public void Angefangen_gehoert_in_Weiterschauen()
|
|
{
|
|
Assert.That(BelongsInContinueWatching(
|
|
Zustand(TimeSpan.FromMinutes(20), Zweistünder)), Is.True);
|
|
}
|
|
|
|
[Test]
|
|
public void Nie_angefangen_gehoert_nicht_hinein()
|
|
{
|
|
Assert.That(BelongsInContinueWatching(
|
|
Zustand(TimeSpan.Zero, Zweistünder)), Is.False);
|
|
}
|
|
|
|
[Test]
|
|
public void Durchgeschaut_gehoert_nicht_hinein()
|
|
{
|
|
Assert.That(BelongsInContinueWatching(
|
|
Zustand(TimeSpan.FromMinutes(118), Zweistünder)), Is.False);
|
|
}
|
|
|
|
[Test]
|
|
public void Weggelegtes_gehoert_nicht_hinein()
|
|
{
|
|
var zustand = Zustand(TimeSpan.FromMinutes(20), Zweistünder);
|
|
zustand.Dismissed = true;
|
|
|
|
Assert.That(BelongsInContinueWatching(zustand), Is.False);
|
|
}
|
|
|
|
/// <summary>Ein zweites Mal angefangen — dann steht er wieder da.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// Das Häkchen bleibt: gesehen hat man ihn ja. Beides nebeneinander ist
|
|
/// genau das, was ohne weggeworfene Position möglich wird.
|
|
/// </remarks>
|
|
[Test]
|
|
public void Ein_gesehener_Titel_kommt_beim_zweiten_Mal_zurueck()
|
|
{
|
|
var zustand = Zustand(TimeSpan.FromMinutes(10), Zweistünder);
|
|
zustand.Watched = true;
|
|
|
|
Assert.That(BelongsInContinueWatching(zustand), Is.True);
|
|
}
|
|
|
|
/* ---------- Die einstellbare Untergrenze ---------- */
|
|
|
|
/// <summary>Hineingeschaut ist noch nicht angefangen.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// Der Anlass: eine Sekunde im Aufmacher, und der Titel stand danach in
|
|
/// „Weiterschauen". Die Stelle wird trotzdem abgelegt — nur die Reihe
|
|
/// bleibt frei. Das ist der Unterschied zu Jellyfin, der oben steht.
|
|
/// </remarks>
|
|
[Test]
|
|
public void Unter_der_Schwelle_gehoert_er_nicht_hinein()
|
|
{
|
|
var zustand = Zustand(TimeSpan.FromSeconds(1), Zweistünder);
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(ProgressRule.BelongsInContinueWatching(zustand, Regeln), Is.False);
|
|
Assert.That(zustand.Position, Is.EqualTo(TimeSpan.FromSeconds(1)),
|
|
"die Stelle bleibt — weggeworfen wird sie nie");
|
|
});
|
|
}
|
|
|
|
[Test]
|
|
public void Genau_auf_der_Schwelle_gehoert_er_hinein()
|
|
{
|
|
Assert.That(ProgressRule.BelongsInContinueWatching(
|
|
Zustand(Regeln.FortsetzenAb, Zweistünder), Regeln), Is.True);
|
|
}
|
|
|
|
/// <summary>„Sofort" ist eine gültige Einstellung — dann zählt jede Sekunde.</summary>
|
|
[Test]
|
|
public void Ohne_Schwelle_zaehlt_die_erste_Sekunde()
|
|
{
|
|
var ohne = Regeln with { FortsetzenAb = TimeSpan.Zero };
|
|
|
|
Assert.That(ProgressRule.BelongsInContinueWatching(
|
|
Zustand(TimeSpan.FromSeconds(1), Zweistünder), ohne), Is.True);
|
|
}
|
|
|
|
/// <summary>Und der Anteil wirkt auch.</summary>
|
|
[Test]
|
|
public void Ein_hoeherer_Anteil_laesst_ihn_laenger_stehen()
|
|
{
|
|
var zustand = Zustand(TimeSpan.FromMinutes(112), Zweistünder); // 93,3 %
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(ProgressRule.BelongsInContinueWatching(zustand, Regeln), Is.False,
|
|
"bei 90 % ist er durch");
|
|
Assert.That(ProgressRule.BelongsInContinueWatching(
|
|
zustand, Regeln with { GesehenAnteil = 0.95 }), Is.True,
|
|
"bei 95 % noch nicht");
|
|
});
|
|
}
|
|
|
|
/// <summary>Unsinn aus der Datenbank wird eingefangen, nicht übernommen.</summary>
|
|
[Test]
|
|
public void Geprueft_bindet_den_Anteil_ein()
|
|
{
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(new Wiedergaberegeln(TimeSpan.FromSeconds(-5), 0).Geprueft(),
|
|
Is.EqualTo(new Wiedergaberegeln(TimeSpan.Zero, 0.5)));
|
|
Assert.That(new Wiedergaberegeln(TimeSpan.Zero, 7).Geprueft().GesehenAnteil,
|
|
Is.EqualTo(1.0));
|
|
});
|
|
}
|
|
|
|
private static UserItemState Zustand(TimeSpan stelle, TimeSpan? laenge) =>
|
|
new() { UserId = Guid.CreateVersion7(), MovieId = Guid.CreateVersion7(), Position = stelle, Runtime = laenge };
|
|
|
|
/// <summary>Ein erkannter Abspann zieht das Häkchen nach vorn.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <b>Der Fall aus dem Gebrauch.</b> Eine Serie hängt sechs Minuten
|
|
/// Abspann an eine Folge von fünfundvierzig. Neunzig Prozent liegen dann
|
|
/// mitten im Abspann, und die Folge gilt erst als gesehen, wenn man ihn
|
|
/// sich ganz ansieht. Die Schwelle dafür herunterzudrehen hiesse, jeden
|
|
/// anderen Titel zu früh abzuhaken.
|
|
/// </remarks>
|
|
[Test]
|
|
public void Der_Abspann_zaehlt_als_Schluss()
|
|
{
|
|
var laenge = TimeSpan.FromMinutes(45);
|
|
var abspann = TimeSpan.FromMinutes(39);
|
|
var regeln = new Wiedergaberegeln { GesehenAnteil = 0.9 };
|
|
|
|
var amAbspann = ProgressRule.Apply(abspann, laenge, regeln, abspann);
|
|
var davor = ProgressRule.Apply(abspann - TimeSpan.FromSeconds(30), laenge, regeln, abspann);
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(amAbspann.Watched, Is.True, "ab dem Abspann ist die Handlung vorbei");
|
|
Assert.That(davor.Watched, Is.False, "eine halbe Minute davor noch nicht");
|
|
});
|
|
}
|
|
|
|
/// <summary>Ein Segment hinter der Schwelle verschiebt nichts nach hinten.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// Sonst machte eine falsch erkannte Segment aus einem Titel, der bei
|
|
/// neunzig Prozent fertig war, einen, der es erst bei fünfundneunzig
|
|
/// ist — und niemand sähe, warum.
|
|
/// </remarks>
|
|
[Test]
|
|
public void Eine_spaete_Segment_schiebt_nichts_nach_hinten()
|
|
{
|
|
var laenge = TimeSpan.FromMinutes(45);
|
|
var regeln = new Wiedergaberegeln { GesehenAnteil = 0.9 };
|
|
|
|
var ergebnis = ProgressRule.Apply(
|
|
TimeSpan.FromMinutes(41), laenge, regeln, abspannAb: TimeSpan.FromMinutes(44));
|
|
|
|
Assert.That(ergebnis.Watched, Is.True);
|
|
}
|
|
|
|
/// <summary>Ohne Segment bleibt alles, wie es war.</summary>
|
|
[Test]
|
|
public void Ohne_Abspann_gilt_der_Anteil()
|
|
{
|
|
var laenge = TimeSpan.FromMinutes(45);
|
|
var regeln = new Wiedergaberegeln { GesehenAnteil = 0.9 };
|
|
|
|
Assert.That(ProgressRule.Apply(TimeSpan.FromMinutes(39), laenge, regeln).Watched, Is.False);
|
|
Assert.That(ProgressRule.Apply(TimeSpan.FromMinutes(41), laenge, regeln).Watched, Is.True);
|
|
}
|
|
}
|