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:
co-authored by
Claude Opus 5
parent
acba683c94
commit
8cf2118723
@@ -127,6 +127,20 @@ enum Wire {
|
|||||||
/// Zu welcher Serie sie gehört — ohne das ist eine Folge für sich
|
/// Zu welcher Serie sie gehört — ohne das ist eine Folge für sich
|
||||||
/// nicht darstellbar: Titel und Bild der Serie fehlten.
|
/// nicht darstellbar: Titel und Bild der Serie fehlten.
|
||||||
let seriesId: String?
|
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".
|
/// Eine Kachel aus „Weiterschauen" oder „Nächste Folge".
|
||||||
|
|||||||
@@ -72,7 +72,16 @@ extension Wire.Episode {
|
|||||||
isFavourite: state?.favourite ?? false,
|
isFavourite: state?.favourite ?? false,
|
||||||
isInProgress: state?.inProgress,
|
isInProgress: state?.inProgress,
|
||||||
availableFrom: publishAt,
|
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))
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -538,6 +538,15 @@ public sealed record SeriesResponse(
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
Guid? LibraryId = null);
|
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(
|
public sealed record EpisodeResponse(
|
||||||
Guid Id, int Season, int Episode, string? Title, string? Overview, string? StillImage,
|
Guid Id, int Season, int Episode, string? Title, string? Overview, string? StillImage,
|
||||||
IReadOnlyList<MediaFileResponse> Files,
|
IReadOnlyList<MediaFileResponse> Files,
|
||||||
@@ -553,7 +562,16 @@ public sealed record EpisodeResponse(
|
|||||||
/// Startseite meldete „Dieser Titel ist auf dem Server nicht mehr
|
/// Startseite meldete „Dieser Titel ist auf dem Server nicht mehr
|
||||||
/// vorhanden" — über einen Titel, den sie selbst gerade angezeigt hatte.
|
/// vorhanden" — über einen Titel, den sie selbst gerade angezeigt hatte.
|
||||||
/// </remarks>
|
/// </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);
|
public sealed record Page<T>(IReadOnlyList<T> Items, int Total);
|
||||||
|
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ public static class LibraryContentEndpoints
|
|||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList());
|
var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList());
|
||||||
var stände = await ZuständeZu(db, wer, folgen: [nachbar.Id]);
|
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
|
// **Ohne eigenes Bild das der Serie.** Eine graue Fläche sagt
|
||||||
// nichts; das Plakat der Serie sagt wenigstens, worum es geht.
|
// 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(),
|
dateien.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren, d.VersionLabel)).ToList(),
|
||||||
stände.GetValueOrDefault(nachbar.Id),
|
stände.GetValueOrDefault(nachbar.Id),
|
||||||
nachbar.PublishAt,
|
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)
|
private static async Task<ILookup<Guid, MediaStreamResponse>> SpurenZu(FlaroContext db, List<Guid> dateien)
|
||||||
@@ -792,6 +817,7 @@ public static class LibraryContentEndpoints
|
|||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList());
|
var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList());
|
||||||
var stände = await ZuständeZu(db, wer, folgen: [id]);
|
var stände = await ZuständeZu(db, wer, folgen: [id]);
|
||||||
|
var marken = await MarkenZu(db, [id]);
|
||||||
|
|
||||||
var wurzel = await MediathekspfadFuer(db, wer,
|
var wurzel = await MediathekspfadFuer(db, wer,
|
||||||
await db.Series.AsNoTracking().Where(x => x.Id == folge.SeriesId)
|
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(),
|
dateien.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren, d.VersionLabel, wurzel)).ToList(),
|
||||||
stände.GetValueOrDefault(id),
|
stände.GetValueOrDefault(id),
|
||||||
folge.PublishAt,
|
folge.PublishAt,
|
||||||
SeriesId: folge.SeriesId));
|
SeriesId: folge.SeriesId,
|
||||||
|
Markers: marken[id].ToList()));
|
||||||
}).RequireAuthorization();
|
}).RequireAuthorization();
|
||||||
|
|
||||||
// **Die Nachbarfolge — vorwärts wie rückwärts.** Reihenfolge ist
|
// **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
|
// **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
|
// eine Folge zu weit gesprungen ist, soll zurück können, ohne über
|
||||||
// die Serienseite zu gehen.
|
// 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) =>
|
app.MapGet("/api/v1/episodes/{id:guid}/next", (Guid id, ClaimsPrincipal wer, FlaroContext db) =>
|
||||||
Nachbarfolge(id, wer, db, vorwaerts: true)).RequireAuthorization();
|
Nachbarfolge(id, wer, db, vorwaerts: true)).RequireAuthorization();
|
||||||
|
|
||||||
@@ -901,6 +943,7 @@ public static class LibraryContentEndpoints
|
|||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList());
|
var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList());
|
||||||
var stände = await ZuständeZu(db, wer, folgen: ids);
|
var stände = await ZuständeZu(db, wer, folgen: ids);
|
||||||
|
var marken = await MarkenZu(db, ids);
|
||||||
|
|
||||||
var serienBild = await db.Series.AsNoTracking()
|
var serienBild = await db.Series.AsNoTracking()
|
||||||
.Where(x => x.Id == id)
|
.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(),
|
.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren, d.VersionLabel, wurzel)).ToList(),
|
||||||
stände.GetValueOrDefault(f.Id),
|
stände.GetValueOrDefault(f.Id),
|
||||||
f.PublishAt,
|
f.PublishAt,
|
||||||
SeriesId: id)).ToList();
|
SeriesId: id,
|
||||||
|
Markers: marken[f.Id].ToList())).ToList();
|
||||||
|
|
||||||
return Results.Ok(new Page<EpisodeResponse>(items, items.Count));
|
return Results.Ok(new Page<EpisodeResponse>(items, items.Count));
|
||||||
}).RequireAuthorization();
|
}).RequireAuthorization();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -170,6 +170,10 @@ builder.Services.AddScoped<IImageReader>(sp => sp.GetRequiredService<ImageCache>
|
|||||||
builder.Services.AddScoped<MetadataEnricher>();
|
builder.Services.AddScoped<MetadataEnricher>();
|
||||||
builder.Services.AddScoped<MatchApplier>();
|
builder.Services.AddScoped<MatchApplier>();
|
||||||
builder.Services.AddSingleton<ScanCoordinator>();
|
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>();
|
builder.Services.AddSingleton<PackageCoordinator>();
|
||||||
|
|
||||||
// **Neben dem Umpacker, nicht an seiner Stelle.** Der baut eine fertige Datei
|
// **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? MovieId { get; init; }
|
||||||
public Guid? SeriesId { 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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbCon
|
|||||||
public DbSet<Genre> Genres => Set<Genre>();
|
public DbSet<Genre> Genres => Set<Genre>();
|
||||||
public DbSet<GenreLink> GenreLinks => Set<GenreLink>();
|
public DbSet<GenreLink> GenreLinks => Set<GenreLink>();
|
||||||
public DbSet<Fremdbewertung> Fremdbewertungen => Set<Fremdbewertung>();
|
public DbSet<Fremdbewertung> Fremdbewertungen => Set<Fremdbewertung>();
|
||||||
|
public DbSet<Marke> Marken => Set<Marke>();
|
||||||
public DbSet<Stichwort> Stichworte => Set<Stichwort>();
|
public DbSet<Stichwort> Stichworte => Set<Stichwort>();
|
||||||
public DbSet<Stichwortlink> Stichwortlinks => Set<Stichwortlink>();
|
public DbSet<Stichwortlink> Stichwortlinks => Set<Stichwortlink>();
|
||||||
public DbSet<Person> People => Set<Person>();
|
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);
|
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 =>
|
b.Entity<GenreLink>(e =>
|
||||||
{
|
{
|
||||||
e.HasKey(x => x.Id);
|
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");
|
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 =>
|
modelBuilder.Entity("Flaro.Core.Library.MediaFile", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -1125,6 +1154,15 @@ namespace Flaro.Data.Migrations
|
|||||||
.OnDelete(DeleteBehavior.Cascade);
|
.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 =>
|
modelBuilder.Entity("Flaro.Core.Library.MediaFile", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Flaro.Core.Libraries.Library", null)
|
b.HasOne("Flaro.Core.Libraries.Library", null)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.Net.Http.Headers;
|
|||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using Flaro.Api.Endpoints;
|
using Flaro.Api.Endpoints;
|
||||||
using Microsoft.AspNetCore.Hosting;
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using Flaro.Data;
|
||||||
using Microsoft.AspNetCore.Mvc.Testing;
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
@@ -25,6 +26,20 @@ public sealed class FlaroServer : IDisposable
|
|||||||
|
|
||||||
public HttpClient Client { get; }
|
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">
|
/// <param name="inhaltswurzel">
|
||||||
/// Ein anderes Stammverzeichnis für den Server — nur nötig, wenn ein Test
|
/// Ein anderes Stammverzeichnis für den Server — nur nötig, wenn ein Test
|
||||||
/// eine ausgelieferte Oberfläche braucht, denn die sucht der Server unter
|
/// eine ausgelieferte Oberfläche braucht, denn die sucht der Server unter
|
||||||
|
|||||||
@@ -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));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -113,12 +113,17 @@ export type Serie = {
|
|||||||
* Das Auffrischen hängt an der Mediathek, nicht am Titel. */
|
* Das Auffrischen hängt an der Mediathek, nicht am Titel. */
|
||||||
libraryId: string | null;
|
libraryId: string | null;
|
||||||
};
|
};
|
||||||
|
/** Ein überspringbarer Abschnitt einer Folge. */
|
||||||
|
export type Marke = { kind: 'intro' | 'credits'; startSeconds: number; endSeconds: number };
|
||||||
|
|
||||||
export type Folge = {
|
export type Folge = {
|
||||||
id: string; season: number; episode: number;
|
id: string; season: number; episode: number;
|
||||||
title: string | null; overview: string | null; stillImage: string | null; files: Datei[];
|
title: string | null; overview: string | null; stillImage: string | null; files: Datei[];
|
||||||
state: Stand | null;
|
state: Stand | null;
|
||||||
/** Zu welcher Serie sie gehört — für die Sprachwahl der Serie. */
|
/** Zu welcher Serie sie gehört — für die Sprachwahl der Serie. */
|
||||||
seriesId?: string | null;
|
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. */
|
/** Wann sie freigegeben ist. Liegt das in der Zukunft, gibt es sie noch nicht. */
|
||||||
publishAt?: string | null;
|
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
|
* „das ist eine Folge nicht" (ein Film etwa), eine 204 „es gibt keine
|
||||||
* weitere", und ein Aussetzer im Netz soll keinen Fehlerkasten über einem
|
* weitere", und ein Aussetzer im Netz soll keinen Fehlerkasten über einem
|
||||||
* laufenden Film öffnen. */
|
* 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(
|
export async function nachbarfolge(
|
||||||
folgeId: string, richtung: 'next' | 'previous',
|
folgeId: string, richtung: 'next' | 'previous',
|
||||||
): Promise<Folge | null> {
|
): Promise<Folge | null> {
|
||||||
|
|||||||
+35
-4
@@ -1,7 +1,7 @@
|
|||||||
import type HlsTyp from 'hls.js';
|
import type HlsTyp from 'hls.js';
|
||||||
import {
|
import {
|
||||||
stromStarten, stromBeenden, untertitelHolen, fortschritt, nachbarfolge,
|
stromStarten, stromBeenden, untertitelHolen, fortschritt, nachbarfolge, folgeLesen,
|
||||||
type Stromauskunft, type Spurauskunft, type Folge,
|
type Stromauskunft, type Spurauskunft, type Folge, type Marke,
|
||||||
} from './api';
|
} from './api';
|
||||||
import { dauer, knopf, meldung, hinweisZeigen } from './util';
|
import { dauer, knopf, meldung, hinweisZeigen } from './util';
|
||||||
import { serienton, serientext, spurFuer } from './sprachwahl';
|
import { serienton, serientext, spurFuer } from './sprachwahl';
|
||||||
@@ -600,6 +600,8 @@ export function spieler(anfrage: Spieleranfrage): void {
|
|||||||
/** Die Nachbarfolgen, sobald der Server sie genannt hat. */
|
/** Die Nachbarfolgen, sobald der Server sie genannt hat. */
|
||||||
let vorige: Folge | null = null;
|
let vorige: Folge | null = null;
|
||||||
let naechste: 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.**
|
// **Eine Folge weiter, eine zurück.**
|
||||||
//
|
//
|
||||||
@@ -627,16 +629,28 @@ export function spieler(anfrage: Spieleranfrage): void {
|
|||||||
unten.className = 'pl-unten';
|
unten.className = 'pl-unten';
|
||||||
unten.append(links, zeitmitte, rechts);
|
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
|
// **Beide Nachbarn gleich zu Beginn holen.** Zwei kleine Anfragen, einmal
|
||||||
// je Wiedergabe. Erst beim Antippen zu fragen hiesse, einen Knopf zu
|
// je Wiedergabe. Erst beim Antippen zu fragen hiesse, einen Knopf zu
|
||||||
// zeigen, von dem noch niemand weiss, ob er etwas tut.
|
// zeigen, von dem noch niemand weiss, ob er etwas tut.
|
||||||
void (async () => {
|
void (async () => {
|
||||||
[vorige, naechste] = await Promise.all([
|
const [davor, danach, diese] = await Promise.all([
|
||||||
nachbarfolge(anfrage.titelId, 'previous'),
|
nachbarfolge(anfrage.titelId, 'previous'),
|
||||||
nachbarfolge(anfrage.titelId, 'next'),
|
nachbarfolge(anfrage.titelId, 'next'),
|
||||||
|
folgeLesen(anfrage.titelId),
|
||||||
]);
|
]);
|
||||||
|
vorige = davor;
|
||||||
|
naechste = danach;
|
||||||
|
marken = diese?.markers ?? [];
|
||||||
if (geschlossen) return;
|
if (geschlossen) return;
|
||||||
vorigeKnopf.classList.toggle('hidden', vorige?.files[0] == null);
|
vorigeKnopf.classList.toggle('hidden', vorige?.files[0] == null);
|
||||||
naechsteKnopf.classList.toggle('hidden', naechste?.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);
|
kleinStand.textContent = laenge > 0 ? `${dauer(jetzt)} / ${dauer(laenge)}` : dauer(jetzt);
|
||||||
kleinGefuellt.style.width = `${Math.min(100, anteil * 100)}%`;
|
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 {
|
function umschalten(): void {
|
||||||
|
|||||||
@@ -918,6 +918,16 @@ textarea.feld {
|
|||||||
* links und rechts übrig bleibt. Mit `space-between` wanderte der
|
* links und rechts übrig bleibt. Mit `space-between` wanderte der
|
||||||
* Abspielknopf, sobald rechts ein Menü dazukam oder links die Lautstärke
|
* Abspielknopf, sobald rechts ein Menü dazukam oder links die Lautstärke
|
||||||
* wegfiel. `1fr auto 1fr` hält ihn fest. */
|
* 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 {
|
.pl-unten {
|
||||||
display: grid; grid-template-columns: 1fr auto 1fr;
|
display: grid; grid-template-columns: 1fr auto 1fr;
|
||||||
align-items: center; gap: var(--s-regular);
|
align-items: center; gap: var(--s-regular);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { ApiFehler,
|
|||||||
film as filmLaden, serie as serieLaden, folgen,
|
film as filmLaden, serie as serieLaden, folgen,
|
||||||
vorschlaege, zuordnen, bildAdresse, istAdmin, gesehen, favorit, weglegen, serienstimmen,
|
vorschlaege, zuordnen, bildAdresse, istAdmin, gesehen, favorit, weglegen, serienstimmen,
|
||||||
type Film, type Serie, type Folge, type Datei, type Stand,
|
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';
|
} from '../api';
|
||||||
import {
|
import {
|
||||||
esc, dauer, groesse, knopf, feld, fehlerzeile, leer, anteil, meldung, hinweisZeigen,
|
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.**
|
// **Bearbeiten — und damit die Bilder — von hier aus erreichbar.**
|
||||||
//
|
//
|
||||||
// Den Dialog gab es längst, aber nur am Dreipunktmenü einer Kachel und
|
// Den Dialog gab es längst, aber nur am Dreipunktmenü einer Kachel und
|
||||||
|
|||||||
Reference in New Issue
Block a user