Vorspann und Abspann — erkannt, abgelegt, überspringbar

Beides mit derselben Rechnung, nur in einem anderen Fenster: vorn die
ersten fünfzehn Minuten, hinten die letzten zehn. Was `Tonabgleich`
findet, hängt allein daran, welchen Ausschnitt man ihm gibt — eine
zweite Erkennung für den Abspann wäre dieselbe Suche ein zweites Mal.

Je Staffel verglichen, nicht je Serie: Serien wechseln ihren Vorspann
zwischen Staffeln, und über alle hinweg fände man nichts oder das
Falsche. Gefundenes über fünf Minuten wird verworfen — zwei Folgen, die
dieselbe Datei tragen, teilen vierzig, und darüber stünde sonst ein
Überspringen-Knopf.

Der Lauf geht auf Zuruf, nicht bei jedem Scan: er liest von jeder Folge
ein Stück Ton und dauert Minuten. Der Knopf dafür steht auf der
Serienseite, bei den Verwalterknöpfen.

Die Marken gehen im Folgenvertrag mit — als „intro" und „credits", die
Wörter, die die Apps von Jellyfin her ohnehin kennen. In der App zeichnet
sie der vorhandene Überspringen-Knopf, der bisher nie etwas zu tun
hatte; im Web ist er neu, rechtsbündig über der Zeitleiste.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-12 21:28:36 +02:00
co-authored by Claude Opus 5
parent acba683c94
commit 8cf2118723
17 changed files with 2009 additions and 10 deletions
+19 -1
View File
@@ -538,6 +538,15 @@ public sealed record SeriesResponse(
/// </remarks>
Guid? LibraryId = null);
/// <summary>Ein überspringbarer Abschnitt — Vorspann oder Abspann.</summary>
///
/// <remarks>
/// <c>Kind</c> ist <c>"intro"</c> oder <c>"credits"</c>: dieselben Wörter,
/// die Jellyfin benutzt. Die Apps kennen sie bereits, und zwei Namen für
/// dieselbe Sache wären zwei Übersetzungen an der Grenze jeder App.
/// </remarks>
public sealed record MarkerResponse(string Kind, double StartSeconds, double EndSeconds);
public sealed record EpisodeResponse(
Guid Id, int Season, int Episode, string? Title, string? Overview, string? StillImage,
IReadOnlyList<MediaFileResponse> Files,
@@ -553,7 +562,16 @@ public sealed record EpisodeResponse(
/// Startseite meldete „Dieser Titel ist auf dem Server nicht mehr
/// vorhanden" — über einen Titel, den sie selbst gerade angezeigt hatte.
/// </remarks>
Guid? SeriesId = null);
Guid? SeriesId = null,
/// <summary>Vorspann und Abspann, sofern erkannt.</summary>
///
/// <remarks>
/// Leer heisst „nicht erkannt", nicht „gibt es nicht". Wer sie sucht,
/// stösst den Lauf an; bis er durch ist, bleibt die Liste leer und die
/// Spieler zeigen schlicht keinen Knopf.
/// </remarks>
IReadOnlyList<MarkerResponse>? Markers = null);
public sealed record Page<T>(IReadOnlyList<T> Items, int Total);
@@ -118,6 +118,7 @@ public static class LibraryContentEndpoints
.ToListAsync();
var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList());
var stände = await ZuständeZu(db, wer, folgen: [nachbar.Id]);
var marken = await MarkenZu(db, [nachbar.Id]);
// **Ohne eigenes Bild das der Serie.** Eine graue Fläche sagt
// nichts; das Plakat der Serie sagt wenigstens, worum es geht.
@@ -133,7 +134,31 @@ public static class LibraryContentEndpoints
dateien.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren, d.VersionLabel)).ToList(),
stände.GetValueOrDefault(nachbar.Id),
nachbar.PublishAt,
SeriesId: diese.SeriesId));
SeriesId: diese.SeriesId,
Markers: marken[nachbar.Id].ToList()));
}
/// <summary>Die Marken zu einer Reihe von Folgen.</summary>
///
/// <remarks>
/// Als Nachschlagewerk und nicht je Folge einzeln: eine Staffel mit
/// zwanzig Folgen wären sonst zwanzig Abfragen für zwanzig kurze Zeilen.
/// </remarks>
private static async Task<ILookup<Guid, MarkerResponse>> MarkenZu(
FlaroContext db, IReadOnlyCollection<Guid> folgen)
{
if (folgen.Count == 0)
return Array.Empty<(Guid, MarkerResponse)>().ToLookup(x => x.Item1, x => x.Item2);
var roh = await db.Marken.AsNoTracking()
.Where(m => folgen.Contains(m.EpisodeId))
.Select(m => new { m.EpisodeId, m.Art, m.VonSekunden, m.BisSekunden })
.ToListAsync();
return roh.ToLookup(
m => m.EpisodeId,
m => new MarkerResponse(
m.Art == Markenart.Intro ? "intro" : "credits", m.VonSekunden, m.BisSekunden));
}
private static async Task<ILookup<Guid, MediaStreamResponse>> SpurenZu(FlaroContext db, List<Guid> dateien)
@@ -792,6 +817,7 @@ public static class LibraryContentEndpoints
.ToListAsync();
var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList());
var stände = await ZuständeZu(db, wer, folgen: [id]);
var marken = await MarkenZu(db, [id]);
var wurzel = await MediathekspfadFuer(db, wer,
await db.Series.AsNoTracking().Where(x => x.Id == folge.SeriesId)
@@ -803,7 +829,8 @@ public static class LibraryContentEndpoints
dateien.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren, d.VersionLabel, wurzel)).ToList(),
stände.GetValueOrDefault(id),
folge.PublishAt,
SeriesId: folge.SeriesId));
SeriesId: folge.SeriesId,
Markers: marken[id].ToList()));
}).RequireAuthorization();
// **Die Nachbarfolge — vorwärts wie rückwärts.** Reihenfolge ist
@@ -813,6 +840,21 @@ public static class LibraryContentEndpoints
// **Beide Richtungen, seit es im Spieler Knöpfe dafür gibt.** Wer
// eine Folge zu weit gesprungen ist, soll zurück können, ohne über
// die Serienseite zu gehen.
// **Vorspann und Abspann suchen — auf Zuruf.**
//
// Der Lauf dauert Minuten: er liest von jeder Folge einer Staffel
// ein Stück Ton und vergleicht die Abdrücke. Deshalb 202 und ein
// Hintergrundlauf statt einer Antwort, auf die jemand wartet.
app.MapPost("/api/v1/series/{id:guid}/marken", async (
Guid id, FlaroContext db, Markenerkennung erkennung, bool erneut = false) =>
{
if (!await db.Series.AsNoTracking().AnyAsync(x => x.Id == id)) return Results.NotFound();
return erkennung.Starten(id, erneut)
? Results.Accepted()
: Einwand.Belegt("Für diese Serie werden gerade Marken gesucht.");
}).RequireAuthorization(FlaroPolicies.Administrator);
app.MapGet("/api/v1/episodes/{id:guid}/next", (Guid id, ClaimsPrincipal wer, FlaroContext db) =>
Nachbarfolge(id, wer, db, vorwaerts: true)).RequireAuthorization();
@@ -901,6 +943,7 @@ public static class LibraryContentEndpoints
.ToListAsync();
var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList());
var stände = await ZuständeZu(db, wer, folgen: ids);
var marken = await MarkenZu(db, ids);
var serienBild = await db.Series.AsNoTracking()
.Where(x => x.Id == id)
@@ -913,7 +956,8 @@ public static class LibraryContentEndpoints
.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren, d.VersionLabel, wurzel)).ToList(),
stände.GetValueOrDefault(f.Id),
f.PublishAt,
SeriesId: id)).ToList();
SeriesId: id,
Markers: marken[f.Id].ToList())).ToList();
return Results.Ok(new Page<EpisodeResponse>(items, items.Count));
}).RequireAuthorization();
+204
View File
@@ -0,0 +1,204 @@
using System.Collections.Concurrent;
using Flaro.Core.Library;
using Flaro.Core.Media;
using Flaro.Data;
using Flaro.Media;
using Microsoft.EntityFrameworkCore;
namespace Flaro.Api;
/// <summary>Sucht Vorspann und Abspann einer Staffel.</summary>
///
/// <remarks>
/// <para>
/// <b>Beides mit derselben Rechnung, nur in einem anderen Fenster.</b>
/// <see cref="Tonabgleich"/> sucht das längste Stück, das zwei Tonabdrücke
/// teilen — was dabei herauskommt, entscheidet allein, welchen Ausschnitt
/// der Folge man ihm gibt. Vorn ist es der Vorspann, hinten der Abspann.
/// Eine zweite Erkennung dafür zu schreiben hiesse, dieselbe Suche zweimal
/// zu haben.
/// </para>
/// <para>
/// <b>Je Staffel, nicht je Serie.</b> Serien wechseln ihren Vorspann
/// zwischen Staffeln — neue Besetzung, neues Lied. Über alle Staffeln
/// verglichen fände man entweder nichts oder das Falsche.
/// </para>
/// <para>
/// <b>Im Hintergrund, mit einem Zustand zum Nachsehen.</b> Eine Staffel
/// abzutasten dauert Minuten; das in einer Anfrage zu tun hiesse, auf ein
/// Zeitlimit zu laufen und am Ende nichts zu haben.
/// </para>
/// </remarks>
public sealed class Markenerkennung(
IServiceScopeFactory scopes,
TimeProvider clock,
ILogger<Markenerkennung> log)
{
/// <summary>Wie viel vom Anfang nach einem Vorspann abgesucht wird.</summary>
///
/// <remarks>
/// Fünfzehn Minuten. Ein Vorspann liegt fast immer in den ersten fünf;
/// manche Serie stellt aber eine Viertelstunde Handlung davor. Weiter
/// zu suchen kostet Zeit und schafft vor allem Gelegenheiten, zwei
/// zufällig ähnliche Takte für einen Vorspann zu halten.
/// </remarks>
public const double Introfenster = 15 * 60;
/// <summary>Wie viel vom Ende nach einem Abspann abgesucht wird.</summary>
public const double Abspannfenster = 10 * 60;
/// <summary>Was ein gefundenes Stück höchstens lang sein darf.</summary>
///
/// <remarks>
/// <b>Die wichtigste Schranke.</b> Zwei Folgen, die dieselbe Datei
/// zweimal enthalten — ein Scanfehler —, teilen vierzig Minuten. Ohne
/// Obergrenze stünde dann ein Überspringen-Knopf über dem halben Film.
/// </remarks>
public const double Hoechstlaenge = 5 * 60;
/// <summary>Welche Staffeln gerade abgetastet werden.</summary>
private readonly ConcurrentDictionary<Guid, bool> _laufend = new();
public bool Laeuft(Guid serieId) => _laufend.ContainsKey(serieId);
/// <summary>Startet den Lauf für eine Serie — oder sagt, dass schon einer läuft.</summary>
public bool Starten(Guid serieId, bool erneut = false)
{
if (!_laufend.TryAdd(serieId, true)) return false;
_ = Task.Run(async () =>
{
try
{
await LaufenAsync(serieId, erneut, CancellationToken.None);
}
catch (Exception fehler)
{
log.LogWarning(fehler, "Markenerkennung für {Serie} abgebrochen.", serieId);
}
finally
{
_laufend.TryRemove(serieId, out _);
}
});
return true;
}
private async Task LaufenAsync(Guid serieId, bool erneut, CancellationToken ct)
{
using var raum = scopes.CreateScope();
var db = raum.ServiceProvider.GetRequiredService<FlaroContext>();
var leser = raum.ServiceProvider.GetRequiredService<Tonabdruckleser>();
if (!await leser.VorhandenAsync(ct))
{
log.LogInformation(
"Kein fpcalc vorhanden — ohne es lässt sich kein Vorspann finden.");
return;
}
// Datei und Laufzeit je Folge. Ohne beides ist eine Folge für den
// Abgleich unbrauchbar: ohne Datei nichts zu lesen, ohne Laufzeit
// kein Fenster fürs Ende.
var folgen = await db.Episodes.AsNoTracking()
.Where(f => f.SeriesId == serieId)
.Join(db.MediaFiles.AsNoTracking(), f => f.Id, d => d.EpisodeId,
(f, d) => new { f.Id, f.SeasonNumber, f.EpisodeNumber, d.Path, d.Duration })
.ToListAsync(ct);
var vorhanden = await db.Marken.AsNoTracking()
.Where(m => folgen.Select(f => f.Id).Contains(m.EpisodeId))
.Select(m => new { m.EpisodeId, m.Art })
.ToListAsync(ct);
var gefunden = 0;
foreach (var staffel in folgen.GroupBy(f => f.SeasonNumber).OrderBy(g => g.Key))
{
var geordnet = staffel.OrderBy(f => f.EpisodeNumber).ToList();
if (geordnet.Count < 2) continue;
foreach (var art in new[] { Markenart.Intro, Markenart.Abspann })
{
// Schon gefunden? Dann nicht noch einmal rechnen — es sei
// denn, jemand verlangt es ausdrücklich.
if (!erneut && geordnet.All(f =>
vorhanden.Any(v => v.EpisodeId == f.Id && v.Art == art)))
continue;
var abdruecke = new List<(Guid Schlüssel, IReadOnlyList<uint> Abdruck)>();
var fensteranfang = new Dictionary<Guid, double>();
foreach (var folge in geordnet)
{
var laufzeit = folge.Duration?.TotalSeconds ?? 0;
var (ab, dauer) = Fenster(art, laufzeit);
if (dauer <= 0) continue;
var abdruck = await leser.LesenAsync(folge.Path, ab, dauer, ct);
if (abdruck.Count == 0) continue;
abdruecke.Add((folge.Id, abdruck));
fensteranfang[folge.Id] = ab;
}
if (abdruecke.Count < 2) continue;
var treffer = Tonabgleich.ReiheAbgleichen(
abdruecke, Tonabdruckleser.SekundenJeAbdruck);
foreach (var (folgenId, stelle) in treffer)
{
if (stelle.Bis - stelle.Von > Hoechstlaenge) continue;
// **Die Zeiten liegen im Fenster, nicht in der Folge.**
// Beim Abspann fängt das Fenster zehn Minuten vor dem
// Ende an; ohne diesen Aufschlag stünde der Knopf bei
// Minute zwei.
var versatz = fensteranfang.GetValueOrDefault(folgenId);
if (await SchreibenAsync(db, folgenId, art,
stelle.Von + versatz, stelle.Bis + versatz, ct))
gefunden++;
}
}
}
if (gefunden > 0) await db.SaveChangesAsync(ct);
log.LogInformation("Markenerkennung für {Serie}: {Anzahl} Marken.", serieId, gefunden);
}
/// <summary>Welcher Ausschnitt der Folge abgetastet wird.</summary>
private static (double Ab, double Dauer) Fenster(Markenart art, double laufzeit)
{
if (art == Markenart.Intro) return (0, laufzeit > 0 ? Math.Min(Introfenster, laufzeit) : Introfenster);
// Ohne bekannte Laufzeit lässt sich das Ende nicht finden.
if (laufzeit <= 0) return (0, 0);
var ab = Math.Max(0, laufzeit - Abspannfenster);
return (ab, laufzeit - ab);
}
private async Task<bool> SchreibenAsync(
FlaroContext db, Guid folgenId, Markenart art, double von, double bis, CancellationToken ct)
{
var alt = await db.Marken
.FirstOrDefaultAsync(m => m.EpisodeId == folgenId && m.Art == art, ct);
if (alt is not null)
{
alt.VonSekunden = von;
alt.BisSekunden = bis;
alt.ErkanntAm = clock.GetUtcNow();
return true;
}
db.Marken.Add(new Marke
{
EpisodeId = folgenId,
Art = art,
VonSekunden = von,
BisSekunden = bis,
ErkanntAm = clock.GetUtcNow(),
});
return true;
}
}
+4
View File
@@ -170,6 +170,10 @@ builder.Services.AddScoped<IImageReader>(sp => sp.GetRequiredService<ImageCache>
builder.Services.AddScoped<MetadataEnricher>();
builder.Services.AddScoped<MatchApplier>();
builder.Services.AddSingleton<ScanCoordinator>();
// Vorspann und Abspann: der Leser ist zustandslos, die Erkennung hält den
// Vermerk, welche Serie gerade abgetastet wird.
builder.Services.AddSingleton<Tonabdruckleser>();
builder.Services.AddSingleton<Markenerkennung>();
builder.Services.AddSingleton<PackageCoordinator>();
// **Neben dem Umpacker, nicht an seiner Stelle.** Der baut eine fertige Datei
@@ -123,3 +123,45 @@ public sealed class Stichwortlink
public Guid? MovieId { get; init; }
public Guid? SeriesId { get; init; }
}
/// <summary>Was für ein Abschnitt eine Marke bezeichnet.</summary>
public enum Markenart
{
/// <summary>Der Vorspann.</summary>
Intro,
/// <summary>Der Abspann.</summary>
Abspann,
}
/// <summary>Ein überspringbarer Abschnitt einer Folge.</summary>
///
/// <remarks>
/// <para>
/// <b>Nur bei Folgen, nicht bei Filmen.</b> Erkannt wird ein Vorspann
/// dadurch, dass er sich wiederholt — über die Folgen einer Staffel hinweg.
/// Ein Film hat nichts, womit er sich vergleichen liesse, und sein Abspann
/// ist nicht derselbe wie der eines anderen Films.
/// </para>
/// <para>
/// <b>Zeiten in Sekunden, nicht in Bildern.</b> Dieselbe Folge liegt
/// vielleicht in zwei Fassungen mit verschiedener Bildrate vor; die Stelle
/// im Film ist dieselbe.
/// </para>
/// </remarks>
public sealed class Marke
{
public Guid Id { get; init; } = Guid.CreateVersion7();
public required Guid EpisodeId { get; init; }
public required Markenart Art { get; init; }
public required double VonSekunden { get; set; }
public required double BisSekunden { get; set; }
/// <summary>Wann sie gefunden wurde.</summary>
///
/// <remarks>
/// Damit ein späterer Lauf weiss, was er nicht noch einmal rechnen muss.
/// Eine Staffel abzutasten dauert Minuten; sie bei jedem Scan erneut
/// abzutasten wäre eine Dauerlast ohne Ertrag.
/// </remarks>
public DateTimeOffset ErkanntAm { get; set; }
}
+13
View File
@@ -76,6 +76,7 @@ public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbCon
public DbSet<Genre> Genres => Set<Genre>();
public DbSet<GenreLink> GenreLinks => Set<GenreLink>();
public DbSet<Fremdbewertung> Fremdbewertungen => Set<Fremdbewertung>();
public DbSet<Marke> Marken => Set<Marke>();
public DbSet<Stichwort> Stichworte => Set<Stichwort>();
public DbSet<Stichwortlink> Stichwortlinks => Set<Stichwortlink>();
public DbSet<Person> People => Set<Person>();
@@ -306,6 +307,18 @@ public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbCon
e.Property(x => x.Anzeige).HasMaxLength(32);
});
b.Entity<Marke>(e =>
{
e.HasKey(x => x.Id);
// Eine Art einmal je Folge: ein zweiter Lauf schreibt um, statt
// eine zweite Zeile anzulegen.
e.HasIndex(x => new { x.EpisodeId, x.Art }).IsUnique();
e.HasOne<Episode>().WithMany().HasForeignKey(x => x.EpisodeId).OnDelete(DeleteBehavior.Cascade);
// Als Zahl, nicht als Text: die Namen stehen im Quelltext und
// sollen sich umbenennen lassen, ohne die Datenbank anzufassen.
e.Property(x => x.Art).HasConversion<int>();
});
b.Entity<GenreLink>(e =>
{
e.HasKey(x => x.Id);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,50 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Flaro.Data.Migrations
{
/// <inheritdoc />
public partial class Marken : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Marken",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
EpisodeId = table.Column<Guid>(type: "TEXT", nullable: false),
Art = table.Column<int>(type: "INTEGER", nullable: false),
VonSekunden = table.Column<double>(type: "REAL", nullable: false),
BisSekunden = table.Column<double>(type: "REAL", nullable: false),
ErkanntAm = table.Column<long>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Marken", x => x.Id);
table.ForeignKey(
name: "FK_Marken_Episodes_EpisodeId",
column: x => x.EpisodeId,
principalTable: "Episodes",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Marken_EpisodeId_Art",
table: "Marken",
columns: new[] { "EpisodeId", "Art" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Marken");
}
}
}
@@ -406,6 +406,35 @@ namespace Flaro.Data.Migrations
b.ToTable("Translations");
});
modelBuilder.Entity("Flaro.Core.Library.Marke", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<int>("Art")
.HasColumnType("INTEGER");
b.Property<double>("BisSekunden")
.HasColumnType("REAL");
b.Property<Guid>("EpisodeId")
.HasColumnType("TEXT");
b.Property<long>("ErkanntAm")
.HasColumnType("INTEGER");
b.Property<double>("VonSekunden")
.HasColumnType("REAL");
b.HasKey("Id");
b.HasIndex("EpisodeId", "Art")
.IsUnique();
b.ToTable("Marken");
});
modelBuilder.Entity("Flaro.Core.Library.MediaFile", b =>
{
b.Property<Guid>("Id")
@@ -1125,6 +1154,15 @@ namespace Flaro.Data.Migrations
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Flaro.Core.Library.Marke", b =>
{
b.HasOne("Flaro.Core.Library.Episode", null)
.WithMany()
.HasForeignKey("EpisodeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.MediaFile", b =>
{
b.HasOne("Flaro.Core.Libraries.Library", null)
@@ -2,6 +2,7 @@ using System.Net.Http.Headers;
using System.Net.Http.Json;
using Flaro.Api.Endpoints;
using Microsoft.AspNetCore.Hosting;
using Flaro.Data;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
@@ -25,6 +26,20 @@ public sealed class FlaroServer : IDisposable
public HttpClient Client { get; }
/// <summary>Ein Blick in die Datenbank — für das, was kein Endpunkt anlegt.</summary>
///
/// <remarks>
/// <b>Sparsam benutzen.</b> Ein Test, der am Server vorbei schreibt,
/// prüft nicht mehr, ob der Server es könnte. Für Marken ist es nötig:
/// sie entstehen aus einem Tonabgleich, und der braucht Mediendateien
/// mit echtem Ton, die es hier nicht gibt.
/// </remarks>
public async Task InDerDatenbank(Func<FlaroContext, Task> tun)
{
using var raum = _fabrik.Services.CreateScope();
await tun(raum.ServiceProvider.GetRequiredService<FlaroContext>());
}
/// <param name="inhaltswurzel">
/// Ein anderes Stammverzeichnis für den Server — nur nötig, wenn ein Test
/// eine ausgelieferte Oberfläche braucht, denn die sucht der Server unter
+171
View File
@@ -0,0 +1,171 @@
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 Tonabgleich.</b> Ob er findet, was er finden soll, steht in
/// <c>TonabgleichTests</c> — mit erfundenen Abdrücken und ohne Mediendateien.
/// Hier geht es um das Stück danach: dass eine gefundene Marke auch bei dem
/// ankommt, der den Knopf zeichnen soll.
/// </remarks>
public class MarkenTests
{
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_Marke_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.Marken.Add(new Marke
{
EpisodeId = folge, Art = Markenart.Intro,
VonSekunden = 30, BisSekunden = 65, ErkanntAm = DateTimeOffset.UtcNow,
});
db.Marken.Add(new Marke
{
EpisodeId = folge, Art = Markenart.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_Marken_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.Marken.Add(new Marke
{
EpisodeId = folge, Art = Markenart.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 Marke bekommt eine leere Liste, nicht die der anderen");
});
}
/// <summary>Ohne erkannte Marken 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));
});
}
}