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
@@ -127,6 +127,20 @@ enum Wire {
/// Zu welcher Serie sie gehört — ohne das ist eine Folge für sich
/// nicht darstellbar: Titel und Bild der Serie fehlten.
let seriesId: String?
/// Vorspann und Abspann, sofern der Server sie gefunden hat.
///
/// Fehlt bei älteren Servern ganz — deshalb optional und nicht eine
/// leere Liste: ein `[]` im Vertrag zwänge jeden Server, das Feld zu
/// schicken.
let markers: [Marker]?
}
/// Ein überspringbarer Abschnitt.
struct Marker: Decodable, Sendable {
/// „intro" oder „credits" — dieselben Wörter wie bei Jellyfin.
let kind: String
let startSeconds: Double
let endSeconds: Double
}
/// Eine Kachel aus „Weiterschauen" oder „Nächste Folge".
@@ -72,7 +72,16 @@ extension Wire.Episode {
isFavourite: state?.favourite ?? false,
isInProgress: state?.inProgress,
availableFrom: publishAt,
versions: [Wire.fassung(id: id, files: files)]
versions: [Wire.fassung(id: id, files: files)],
// **Vorspann und Abspann, wenn der Server sie kennt.**
// Ältere Server schicken das Feld gar nicht; dann ist die
// Liste leer und die Spieler zeigen schlicht keinen Knopf.
markers: (markers ?? []).map {
MediaMarker(
kind: MediaMarker.Kind(rawValue: $0.kind) ?? .unknown,
startMilliseconds: Int($0.startSeconds * 1000),
endMilliseconds: Int($0.endSeconds * 1000))
}
)
}
}
+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));
});
}
}
+26
View File
@@ -113,12 +113,17 @@ export type Serie = {
* Das Auffrischen hängt an der Mediathek, nicht am Titel. */
libraryId: string | null;
};
/** Ein überspringbarer Abschnitt einer Folge. */
export type Marke = { kind: 'intro' | 'credits'; startSeconds: number; endSeconds: number };
export type Folge = {
id: string; season: number; episode: number;
title: string | null; overview: string | null; stillImage: string | null; files: Datei[];
state: Stand | null;
/** Zu welcher Serie sie gehört — für die Sprachwahl der Serie. */
seriesId?: string | null;
/** Vorspann und Abspann, sofern der Server sie gefunden hat. */
markers?: Marke[] | null;
/** Wann sie freigegeben ist. Liegt das in der Zukunft, gibt es sie noch nicht. */
publishAt?: string | null;
};
@@ -632,6 +637,27 @@ export const naechsteFolgen = (take = 20) =>
* „das ist eine Folge nicht" (ein Film etwa), eine 204 „es gibt keine
* weitere", und ein Aussetzer im Netz soll keinen Fehlerkasten über einem
* laufenden Film öffnen. */
/** Eine Folge für sich — tolerant, wie die Nachbarn.
*
* Für den Spieler: er bekommt nur eine Kennung und möchte wissen, ob es
* dazu Marken gibt. Eine 404 heisst „kein Folgentitel", und mehr hängt
* nicht daran. */
/** Stösst die Suche nach Vorspann und Abspann einer Serie an.
*
* Antwortet mit 202: der Lauf dauert Minuten — er liest von jeder Folge
* einer Staffel ein Stück Ton —, und darauf wartet niemand. */
export const markenSuchen = (serieId: string, erneut = false) =>
anfrage<void>(
`/series/${serieId}/marken${erneut ? '?erneut=true' : ''}`, { method: 'POST' });
export async function folgeLesen(id: string): Promise<Folge | null> {
try {
return (await anfrage<Folge | undefined>(`/episodes/${id}`)) ?? null;
} catch {
return null;
}
}
export async function nachbarfolge(
folgeId: string, richtung: 'next' | 'previous',
): Promise<Folge | null> {
+35 -4
View File
@@ -1,7 +1,7 @@
import type HlsTyp from 'hls.js';
import {
stromStarten, stromBeenden, untertitelHolen, fortschritt, nachbarfolge,
type Stromauskunft, type Spurauskunft, type Folge,
stromStarten, stromBeenden, untertitelHolen, fortschritt, nachbarfolge, folgeLesen,
type Stromauskunft, type Spurauskunft, type Folge, type Marke,
} from './api';
import { dauer, knopf, meldung, hinweisZeigen } from './util';
import { serienton, serientext, spurFuer } from './sprachwahl';
@@ -600,6 +600,8 @@ export function spieler(anfrage: Spieleranfrage): void {
/** Die Nachbarfolgen, sobald der Server sie genannt hat. */
let vorige: Folge | null = null;
let naechste: Folge | null = null;
/** Vorspann und Abspann dieser Folge — leer heisst: keine erkannt. */
let marken: Marke[] = [];
// **Eine Folge weiter, eine zurück.**
//
@@ -627,16 +629,28 @@ export function spieler(anfrage: Spieleranfrage): void {
unten.className = 'pl-unten';
unten.append(links, zeitmitte, rechts);
bedienung.append(zeitleiste, unten);
// **„Intro überspringen" über der Leiste, rechtsbündig.**
//
// Dort erwartet man ihn — so macht es jeder Spieler —, und dort verdeckt
// er weder das Bild noch die Zeitleiste. Er steht im selben Block wie die
// Bedienung und blendet mit ihr ein und aus.
const ueberspringen = document.createElement('button');
ueberspringen.className = 'btn pl-ueberspringen hidden';
bedienung.append(ueberspringen, zeitleiste, unten);
// **Beide Nachbarn gleich zu Beginn holen.** Zwei kleine Anfragen, einmal
// je Wiedergabe. Erst beim Antippen zu fragen hiesse, einen Knopf zu
// zeigen, von dem noch niemand weiss, ob er etwas tut.
void (async () => {
[vorige, naechste] = await Promise.all([
const [davor, danach, diese] = await Promise.all([
nachbarfolge(anfrage.titelId, 'previous'),
nachbarfolge(anfrage.titelId, 'next'),
folgeLesen(anfrage.titelId),
]);
vorige = davor;
naechste = danach;
marken = diese?.markers ?? [];
if (geschlossen) return;
vorigeKnopf.classList.toggle('hidden', vorige?.files[0] == null);
naechsteKnopf.classList.toggle('hidden', naechste?.files[0] == null);
@@ -741,6 +755,23 @@ export function spieler(anfrage: Spieleranfrage): void {
kleinStand.textContent = laenge > 0 ? `${dauer(jetzt)} / ${dauer(laenge)}` : dauer(jetzt);
kleinGefuellt.style.width = `${Math.min(100, anteil * 100)}%`;
markenstand(jetzt);
}
/** Zeigt den Überspringen-Knopf, solange man in einer Marke steht.
*
* **Keine eigene Uhr.** Die Zeit wird ohnehin bei jedem Fortschreiten
* nachgeführt; ein zweiter Zeitgeber dafür liefe neben dem ersten her und
* wäre eine zweite Stelle, an der etwas hängen bleibt. */
function markenstand(jetzt: number): void {
const drin = marken.find(m => jetzt >= m.startSeconds && jetzt < m.endSeconds);
ueberspringen.classList.toggle('hidden', !drin);
if (!drin) return;
const text = drin.kind === 'intro' ? 'Intro überspringen' : 'Abspann überspringen';
if (ueberspringen.textContent !== text) ueberspringen.textContent = text;
ueberspringen.onclick = e => { e.stopPropagation(); void springen(drin.endSeconds); };
}
function umschalten(): void {
+10
View File
@@ -918,6 +918,16 @@ textarea.feld {
* links und rechts übrig bleibt. Mit `space-between` wanderte der
* Abspielknopf, sobald rechts ein Menü dazukam oder links die Lautstärke
* wegfiel. `1fr auto 1fr` hält ihn fest. */
/* „Intro überspringen" — rechtsbündig über der Zeitleiste.
*
* Als eigene Zeile im Bedienblock, nicht als Überzug über dem Bild: so
* blendet er mit der Bedienung ein und aus und verdeckt nie die Leiste. */
.pl-ueberspringen {
align-self: flex-end;
margin: 0 0 2px auto;
display: block;
}
.pl-unten {
display: grid; grid-template-columns: 1fr auto 1fr;
align-items: center; gap: var(--s-regular);
+18 -1
View File
@@ -2,7 +2,7 @@ import { ApiFehler,
film as filmLaden, serie as serieLaden, folgen,
vorschlaege, zuordnen, bildAdresse, istAdmin, gesehen, favorit, weglegen, serienstimmen,
type Film, type Serie, type Folge, type Datei, type Stand,
type Mitwirkender, type Spur, aehnliche, metadatenAuffrischen, scanstand,
type Mitwirkender, type Spur, aehnliche, metadatenAuffrischen, scanstand, markenSuchen,
} from '../api';
import {
esc, dauer, groesse, knopf, feld, fehlerzeile, leer, anteil, meldung, hinweisZeigen,
@@ -476,6 +476,23 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
}));
}
// **Vorspann und Abspann suchen.**
//
// Nur bei einer Serie: erkannt wird ein Vorspann dadurch, dass er sich
// über die Folgen einer Staffel wiederholt. Ein Film hat nichts, womit
// er sich vergleichen liesse.
//
// Auf Zuruf und nicht von selbst: der Lauf liest von jeder Folge ein
// Stück Ton, und das dauert Minuten. Ihn bei jedem Scan mitlaufen zu
// lassen hiesse, jeden Scan um ein Vielfaches zu verlängern.
if (melden && istAdmin() && art === 'serien') {
reihe.appendChild(rundKnopf('uhr', false, 'Vorspann und Abspann suchen',
async () => {
await markenSuchen(eintrag.id);
hinweisZeigen('Vorspann und Abspann werden gesucht. Das dauert ein paar Minuten.');
}));
}
// **Bearbeiten — und damit die Bilder — von hier aus erreichbar.**
//
// Den Dialog gab es längst, aber nur am Dreipunktmenü einer Kachel und