Er packte um, was gar nicht am Umpacken lag

Der Browser bekam stillschweigend Apples Geräteprofil — und mit ihm HEVC, AC3
und EAC3, von denen er keines öffnet. Der Server packte die Datei
pflichtschuldig nach MP4 um, liess Bild und Ton dabei unberührt, und heraus
kam eine tadellose Datei, mit der Chrome nichts anfangen konnte. Umpacken
fasst das Bild nun einmal nicht an.

Jetzt sagt das Gerät selbst, was es kann, und es gibt einen zweiten Weg neben
der fertigen Datei: HLS, das nach Sekunden anfängt statt nach Minuten.
Dasselbe Rohr erledigt beides — mkv nach fMP4 mit „-c:v copy", 4K nach 480p
mit libx264 — und eine MP4, die der Browser ohnehin öffnet, geht ganz ohne
ffmpeg hinaus.

Dazu eine eigene Steuerleiste: Tonspur und Untertitel während der Wiedergabe
umschaltbar, vier Auflösungsstufen, Tastenkürzel, und bei einem Fehler ein
deutscher Satz statt eines schwarzen Bildes.

Gemessen statt vermutet: Chromium antwortet auf canPlayType für HLS mit
"maybe" und kann es dann doch nicht — deshalb entscheidet Hls.isSupported().
hls.js wiegt 372 KB und wird erst beim Abspielen geholt; die Oberfläche
bleibt bei 109.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-05 18:52:27 +02:00
co-authored by Claude Opus 5
parent 557708b769
commit 4853200d5c
16 changed files with 2894 additions and 65 deletions
@@ -430,6 +430,47 @@ public sealed record BulkWatchedResponse(int Changed, IReadOnlyList<Guid> Unknow
/// <summary>Welche Titel neu geholt werden sollen — leer heisst: alle.</summary>
public sealed record RefreshRequest(IReadOnlyList<Guid>? Ids);
/// <summary>Was ein Spieler kann und was er sehen will.</summary>
///
/// <param name="Containers">Schachteln, die das Gerät öffnet — „mp4", „webm".</param>
/// <param name="Height">
/// Die gewünschte Stufe: 1080, 720 oder 480. <c>null</c> heisst Originalauflösung.
/// </param>
/// <param name="AudioStream">
/// Welche Tonspur — die Nummer aus der Datei. <c>null</c> nimmt die erste.
/// </param>
/// <param name="StartSeconds">Ab welcher Sekunde des Films.</param>
///
/// <remarks>
/// <b>Das Gerät sagt, was es kann, statt dass der Server es rät.</b> Vorher
/// bekam jeder Browser Apples Profil — und mit ihm HEVC, AC3 und EAC3, von
/// denen er keines öffnen kann.
/// </remarks>
public sealed record StreamRequest(
IReadOnlyList<string>? Containers,
IReadOnlyList<string>? VideoCodecs,
IReadOnlyList<string>? AudioCodecs,
int? Height = null,
int? AudioStream = null,
double? StartSeconds = null);
/// <summary>Eine wählbare Spur, so wie sie im Menü steht.</summary>
public sealed record SpurAuskunft(int Index, string Label, string? Language, bool IsDefault);
/// <param name="StartSeconds">
/// Ab welcher Sekunde des Films dieser Strom beginnt. <b>Der Spieler zählt ab
/// null</b> — ohne diesen Wert ginge jeder gemeldete Stand um den Vorlauf daneben.
/// </param>
public sealed record StreamResponse(
Guid SessionId,
string Url,
string Method,
double StartSeconds,
int? Height,
int? AudioStream,
IReadOnlyList<SpurAuskunft> AudioStreams,
IReadOnlyList<SpurAuskunft> SubtitleStreams);
public sealed record RatingRequest(int? Rating);
/// <summary>Was ein Benutzer mit einem Titel zu tun hatte.</summary>
@@ -0,0 +1,336 @@
using System.Security.Claims;
using System.Text;
using Flaro.Core.Delivery;
using Flaro.Core.Library;
using Flaro.Data;
using Flaro.Media;
using Microsoft.EntityFrameworkCore;
namespace Flaro.Api.Endpoints;
/// <summary>Wiedergabe, die sofort anfängt — statt auf eine fertige Datei zu warten.</summary>
///
/// <remarks>
/// <para>
/// <b>Warum es diesen zweiten Weg gibt.</b> <see cref="DeliveryEndpoints"/>
/// baut eine fertige MP4 und antwortet solange mit 202. Für eine App, die den
/// Film mitnehmen will, ist das richtig. Im Browser heisst es: Minuten vor dem
/// ersten Bild — und bei einem Bildcodec, den der Browser nicht kennt, ein
/// Umpacken, das nichts nützt, weil es das Bild gar nicht anfasst.
/// </para>
/// <para>
/// <b>Der Browser sagt, was er kann.</b> Vorher bekam er stillschweigend
/// Apples Geräteprofil, das HEVC, AC3 und EAC3 erlaubt — und stand dann vor
/// einer Datei, von der er nichts öffnen konnte.
/// </para>
/// </remarks>
public static class StreamEndpoints
{
public static void MapStream(this IEndpointRouteBuilder app)
{
// **Eine Sitzung anlegen.** Der Rumpf trägt, was das Gerät kann und
// was der Benutzer gewählt hat.
app.MapPost("/api/v1/files/{id:guid}/stream", async (
Guid id, StreamRequest anfrage, ClaimsPrincipal wer,
Streamkoordinator koordinator, FlaroContext db) =>
{
if (PlaybackEndpoints.Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
var profil = Profil(anfrage);
var hoehe = anfrage.Height is { } gewuenscht
&& Stromentscheid.Stufen.Contains(gewuenscht) ? gewuenscht : (int?)null;
try
{
// **Was der Browser ohnehin öffnet, geht unverändert hinaus.**
//
// Eine MP4 mit H.264 und AAC durch ffmpeg zu schicken wäre ein
// Prozess und ein Kratzplatzordner für nichts — und der
// Browser verlöre dabei das, was er von sich aus am besten
// kann: springen über Bereichsanfragen, ohne dass irgendwo
// etwas neu gerechnet wird.
//
// **Nur ohne Wünsche.** Wer eine Tonspur oder eine Stufe
// wählt, bekommt eine Sitzung: beides lässt sich an einer
// durchgereichten Datei nicht einstellen.
if (hoehe is null && anfrage.AudioStream is null
&& await Durchreichbar(db, id, profil) is { } datei)
{
return Results.Ok(await Direktauskunft(db, datei, anfrage.StartSeconds ?? 0));
}
var sitzung = await koordinator.StartenAsync(
id, benutzer, profil, hoehe, anfrage.AudioStream, anfrage.StartSeconds ?? 0);
return Results.Ok(await Auskunft(db, sitzung));
}
catch (InvalidOperationException fehler)
{
// Ein deutscher Satz, kein Fehlercode — hier wird gelesen,
// nicht gesucht. Die Einzelheiten stehen im Protokoll.
return Results.Problem(fehler.Message, statusCode: StatusCodes.Status409Conflict);
}
}).RequireAuthorization();
// **Die Playlist.** Sie wächst, während ffmpeg schreibt.
app.MapGet("/api/v1/stream/{sitzung:guid}/index.m3u8", async (
Guid sitzung, Streamkoordinator koordinator, CancellationToken ct) =>
{
if (koordinator.Sitzung(sitzung) is not { } laufend) return Results.NotFound();
koordinator.Lebenszeichen(sitzung);
// **Hier wird gewartet, nicht abgewiesen.**
//
// Zuerst stand hier eine 204, solange noch kein Segment da war —
// der Spieler sollte gleich noch einmal fragen. Gemessen: hls.js
// hält jede Antwort ohne Playlist für einen Fehler und gibt sofort
// auf. Eine Antwort, die ein paar Sekunden braucht, ist für ihn
// dagegen das Normale.
//
// Gemessen am Prüfbestand: beim Umpacken liegt das erste Segment
// nach deutlich unter einer Sekunde vor. Die Spanne hier ist für
// den anderen Fall gedacht — ein 4K-Film, der neu gerechnet wird.
for (var versuch = 0; versuch < 60; versuch++)
{
if (ct.IsCancellationRequested) return Results.NoContent();
if (koordinator.Sitzung(sitzung) is not { } jetzt) return Results.NotFound();
if (jetzt.Fehler is { Length: > 0 })
{
return Results.Problem(
"Die Wiedergabe liess sich nicht aufbereiten. "
+ "Die Einzelheiten stehen im Protokoll von heute.",
statusCode: StatusCodes.Status500InternalServerError);
}
if (Streamkoordinator.PlaylistBereit(jetzt.Ordner))
return Results.File(Path.Combine(jetzt.Ordner, HlsArgumente.Playlist),
"application/vnd.apple.mpegurl");
// **Hört ffmpeg auf, ohne ein Segment geschrieben zu haben**,
// kommt auch keines mehr. Weiterzuwarten hiesse, eine halbe
// Minute auf etwas zu warten, das es nicht geben wird.
if (jetzt.Beendet)
{
return Results.Problem(
"Für diese Datei kam keine abspielbare Fassung zustande. "
+ "Die Einzelheiten stehen im Protokoll von heute.",
statusCode: StatusCodes.Status500InternalServerError);
}
await Task.Delay(250, ct);
}
return Results.Problem(
"Die Aufbereitung dauert ungewöhnlich lange. Versuche eine kleinere Auflösung.",
statusCode: StatusCodes.Status504GatewayTimeout);
}).RequireAuthorization();
// **Die Segmente.** Jedes Abholen ist zugleich das Lebenszeichen.
app.MapGet("/api/v1/stream/{sitzung:guid}/{datei}", (
Guid sitzung, string datei, Streamkoordinator koordinator) =>
{
if (koordinator.Sitzung(sitzung) is not { } laufend) return Results.NotFound();
// **Nur der blosse Name.** Ohne diese Prüfung käme man mit
// `../../config/flaro.db` an die Datenbank — ein Pfad aus der
// Anfrage darf nie ungeprüft an das Dateisystem.
if (datei != Path.GetFileName(datei) || datei.Contains("..", StringComparison.Ordinal))
return Results.BadRequest();
if (!datei.EndsWith(".m4s", StringComparison.Ordinal)
&& !datei.EndsWith(".mp4", StringComparison.Ordinal))
{
return Results.NotFound();
}
koordinator.Lebenszeichen(sitzung);
var pfad = Path.Combine(laufend.Ordner, datei);
return File.Exists(pfad)
? Results.File(pfad, "video/mp4", enableRangeProcessing: true)
: Results.NotFound();
}).RequireAuthorization();
// **Schliessen heisst aufräumen.** Ohne das liefe ffmpeg weiter, bis
// die Geduld der Sitzung abgelaufen ist — und schriebe solange den
// ganzen Film auf den Kratzplatz.
app.MapDelete("/api/v1/stream/{sitzung:guid}", (Guid sitzung, Streamkoordinator koordinator) =>
{
koordinator.Beenden(sitzung);
return Results.NoContent();
}).RequireAuthorization();
// **Dasselbe noch einmal als POST, für `sendBeacon`.**
//
// Beim Schliessen eines Reiters kommt eine gewöhnliche Anfrage oft
// nicht mehr durch — der Browser bricht sie mit der Seite ab.
// `sendBeacon` überlebt das, kann aber nur POST. Ohne diesen Weg
// bliebe nach jedem geschlossenen Reiter ein ffmpeg stehen, bis die
// Geduld der Sitzung abgelaufen ist.
app.MapPost("/api/v1/stream/{sitzung:guid}/schliessen", (
Guid sitzung, Streamkoordinator koordinator) =>
{
koordinator.Beenden(sitzung);
return Results.NoContent();
}).RequireAuthorization();
// **Untertitel als eigene Datei, nicht im Strom.**
//
// So lassen sie sich während der Wiedergabe umschalten, ohne dass
// irgendetwas neu gerechnet wird. In einer fMP4-Segmentierung hätten
// sie ohnehin keinen Platz.
app.MapGet("/api/v1/files/{id:guid}/subtitles/{spur:int}.vtt", async (
Guid id, int spur, FlaroContext db, UntertitelLeser leser, CancellationToken ct) =>
{
var datei = await db.MediaFiles.AsNoTracking().FirstOrDefaultAsync(f => f.Id == id, ct);
if (datei is null || !File.Exists(datei.Path)) return Results.NotFound();
var vtt = await leser.AlsWebVttAsync(datei.Path, spur, ct);
return vtt is null
? Results.NotFound()
: Results.Text(vtt, "text/vtt", Encoding.UTF8);
}).RequireAuthorization();
}
/// <summary>Die Datei, wenn der Browser sie unverändert öffnen kann — sonst <c>null</c>.</summary>
private static async Task<Guid?> Durchreichbar(FlaroContext db, Guid id, DeviceProfile profil)
{
var datei = await db.MediaFiles.AsNoTracking().FirstOrDefaultAsync(f => f.Id == id);
if (datei is null || !File.Exists(datei.Path)) return null;
var spuren = await db.MediaStreams.AsNoTracking()
.Where(s => s.MediaFileId == id).OrderBy(s => s.Index).ToListAsync();
var gelesen = spuren.Select(s => new ProbedStream(
s.Index, s.Kind, s.Codec, s.Language, s.Title,
s.IsDefault, s.IsForced, s.Channels, s.Width, s.Height)).ToList();
return Stromentscheid.Fuer(datei.Container, gelesen, profil).Art == Stromart.Direkt
? datei.Id
: null;
}
private static async Task<StreamResponse> Direktauskunft(FlaroContext db, Guid datei, double ab)
{
var spuren = await db.MediaStreams.AsNoTracking()
.Where(s => s.MediaFileId == datei)
.OrderBy(s => s.Index)
.ToListAsync();
return new StreamResponse(
// **Keine Sitzung, also keine Kennung.** Der Spieler merkt daran,
// dass es nichts zu beenden und nichts aufzuräumen gibt.
SessionId: Guid.Empty,
Url: $"/api/v1/files/{datei}/download",
Method: "direct",
// **Null, nicht `ab`.** Bei einer durchgereichten Datei springt
// der Spieler selbst; es gibt keinen Vorlauf, den er aufrechnen
// müsste. Ihn hier zu melden hiesse, die Stelle doppelt zu zählen.
StartSeconds: 0,
Height: null,
AudioStream: spuren.FirstOrDefault(s => s.Kind == MediaStreamKind.Audio)?.Index,
AudioStreams: spuren.Where(s => s.Kind == MediaStreamKind.Audio)
.Select(s => new SpurAuskunft(s.Index, Benennung(s), s.Language, s.IsDefault))
.ToList(),
SubtitleStreams: spuren.Where(s => s.Kind == MediaStreamKind.Subtitle)
.Where(s => s.Codec is null || !IstBild(s.Codec))
.Select(s => new SpurAuskunft(s.Index, Benennung(s), s.Language, s.IsDefault))
.ToList());
}
/// <summary>Das Geräteprofil aus der Anfrage — oder, wenn keines kam, Apples.</summary>
///
/// <remarks>
/// <b>Die Rückfallebene ist nicht harmlos.</b> Sie erlaubt HEVC und EAC3;
/// ein Browser, der sein Profil nicht mitschickt, bekommt damit womöglich
/// etwas, das er nicht abspielen kann. Alte Clients sollen aber nicht
/// plötzlich gar nichts mehr bekommen — die Apps schicken ohnehin bald
/// ihr eigenes.
/// </remarks>
private static DeviceProfile Profil(StreamRequest anfrage)
{
if (anfrage.Containers is not { Count: > 0 }
|| anfrage.VideoCodecs is not { Count: > 0 }
|| anfrage.AudioCodecs is not { Count: > 0 })
{
return DeviceProfile.AppleDefault;
}
return new DeviceProfile(
new HashSet<string>(anfrage.Containers, StringComparer.OrdinalIgnoreCase),
new HashSet<string>(anfrage.VideoCodecs, StringComparer.OrdinalIgnoreCase),
new HashSet<string>(anfrage.AudioCodecs, StringComparer.OrdinalIgnoreCase));
}
private static async Task<StreamResponse> Auskunft(FlaroContext db, Stromsitzung sitzung)
{
var spuren = await db.MediaStreams.AsNoTracking()
.Where(s => s.MediaFileId == sitzung.DateiId)
.OrderBy(s => s.Index)
.ToListAsync();
return new StreamResponse(
SessionId: sitzung.Kennung,
Url: $"/api/v1/stream/{sitzung.Kennung}/index.m3u8",
Method: sitzung.Art switch
{
Stromart.Direkt => "direct",
Stromart.Umpacken => "remux",
_ => "transcode",
},
StartSeconds: sitzung.Ab,
Height: sitzung.Hoehe,
AudioStream: sitzung.Tonspur,
AudioStreams: spuren.Where(s => s.Kind == MediaStreamKind.Audio)
.Select(s => new SpurAuskunft(s.Index, Benennung(s), s.Language, s.IsDefault))
.ToList(),
SubtitleStreams: spuren.Where(s => s.Kind == MediaStreamKind.Subtitle)
.Where(s => s.Codec is null || !IstBild(s.Codec))
.Select(s => new SpurAuskunft(s.Index, Benennung(s), s.Language, s.IsDefault))
.ToList());
}
/// <summary>Bildbasierte Untertitel lassen sich nicht nach WebVTT wandeln.</summary>
///
/// <remarks>
/// <b>Sie anzubieten wäre eine Zusage, die nicht eingelöst werden kann.</b>
/// PGS ist auf jedem Blu-ray-Rip die Regel — ein Eintrag im Menü, der zu
/// nichts führt, ist schlechter als kein Eintrag.
/// </remarks>
private static bool IstBild(string codec) =>
codec is "hdmv_pgs_subtitle" or "pgssub" or "dvd_subtitle" or "dvdsub" or "xsub";
/// <summary>Wie eine Spur im Menü heisst.</summary>
private static string Benennung(MediaStream spur)
{
if (spur.Title is { Length: > 0 } eigen) return eigen;
var sprache = Sprache(spur.Language);
var teile = new List<string> { sprache };
if (spur.Channels is { } k and > 2) teile.Add($"{k} Kanäle");
if (spur.IsForced) teile.Add("erzwungen");
return string.Join(" · ", teile);
}
/// <summary>Dreibuchstabige Kennungen in Worte — für die üblichen.</summary>
///
/// <remarks>
/// <b>Keine vollständige Tabelle.</b> „deu" steht auf fast jeder Datei
/// hier; die übrigen bleiben, wie sie sind, statt eine Liste zu pflegen,
/// die nie fertig wird.
/// </remarks>
private static string Sprache(string? kennung) => kennung?.ToLowerInvariant() switch
{
null or "" or "und" => "Unbenannt",
"deu" or "ger" or "de" => "Deutsch",
"eng" or "en" => "Englisch",
"fra" or "fre" or "fr" => "Französisch",
"ita" or "it" => "Italienisch",
"spa" or "es" => "Spanisch",
"jpn" or "ja" => "Japanisch",
_ => kennung!.ToUpperInvariant(),
};
}
+6
View File
@@ -104,6 +104,11 @@ builder.Services.AddScoped<MetadataEnricher>();
builder.Services.AddScoped<MatchApplier>();
builder.Services.AddSingleton<ScanCoordinator>();
builder.Services.AddSingleton<PackageCoordinator>();
// **Neben dem Umpacker, nicht an seiner Stelle.** Der baut eine fertige Datei
// zum Mitnehmen; dieser lässt sie laufen, während sie entsteht.
builder.Services.AddSingleton<Streamkoordinator>();
builder.Services.AddSingleton<UntertitelLeser>();
// Im Arbeitsspeicher und nur dort: wer gerade was schaut, ist eine Frage
// ueber den Augenblick. Nach einem Neustart lautet die Antwort: niemand.
builder.Services.AddSingleton<PlaybackSessions>();
@@ -259,6 +264,7 @@ app.MapLibraryContent();
app.MapSettings();
app.MapImages();
app.MapDelivery();
app.MapStream();
app.MapPlayback();
app.MapMatching();
app.MapArtwork();
+310
View File
@@ -0,0 +1,310 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using Flaro.Core.Delivery;
using Flaro.Core.Library;
using Flaro.Data;
using Flaro.Media;
using Microsoft.EntityFrameworkCore;
namespace Flaro.Api;
/// <summary>Eine laufende Wiedergabe mit ihrem ffmpeg dahinter.</summary>
///
/// <param name="Ab">
/// Ab welcher Sekunde des Films diese Sitzung beginnt. <b>Die Zeitachse des
/// Spielers fängt bei null an</b>, nicht beim Film — wer ab Minute 30
/// einsteigt, dessen Spieler steht bei 0, während der Film bei 1800 steht.
/// Ohne diesen Wert ginge jeder gemeldete Stand um eine halbe Stunde daneben.
/// </param>
public sealed record Stromsitzung(
Guid Kennung,
Guid DateiId,
Guid Benutzer,
string Ordner,
double Ab,
int? Hoehe,
int? Tonspur,
Stromart Art,
DateTimeOffset Begonnen)
{
/// <summary>Wann zuletzt ein Segment abgeholt wurde.</summary>
///
/// <remarks>
/// <b>Daran hängt das Aufräumen.</b> Ein geschlossener Browser sagt
/// niemandem Bescheid; bliebe die Sitzung stehen, liefe ffmpeg weiter und
/// schriebe den ganzen Film auf den Kratzplatz.
/// </remarks>
public DateTimeOffset Gesehen { get; set; } = Begonnen;
public string? Fehler { get; set; }
public bool Beendet { get; set; }
}
/// <summary>Verwaltet laufende Ströme: starten, bedienen, aufräumen.</summary>
///
/// <remarks>
/// <para>
/// <b>Neben <see cref="PackageCoordinator"/>, nicht an seiner Stelle.</b> Der
/// baut eine fertige Datei zum Mitnehmen — mit Länge, Bereichsanfragen und
/// Fortsetzen nach einem Abbruch; das bleibt für die Apps richtig. Hier geht
/// es um etwas anderes: der erste Ton soll nach Sekunden kommen, nicht nach
/// Minuten.
/// </para>
/// <para>
/// <b>Eine Sitzung je Wiedergabe, nicht je Datei.</b> Zwei Leute können
/// denselben Film in verschiedenen Auflösungen und mit verschiedenen
/// Tonspuren sehen. Das nach Dateikennung zu führen hiesse, dass der zweite
/// bekommt, was der erste gewählt hat.
/// </para>
/// </remarks>
public sealed class Streamkoordinator(
IServiceScopeFactory scopes,
FlaroPaths pfade,
FfmpegOptions ffmpeg,
TimeProvider clock,
ILogger<Streamkoordinator> log) : IDisposable
{
private readonly ConcurrentDictionary<Guid, Eintrag> _sitzungen = new();
private sealed record Eintrag(Stromsitzung Sitzung, Process Prozess, CancellationTokenSource Abbruch);
/// <summary>Nach dieser Stille gilt eine Sitzung als verlassen.</summary>
///
/// <remarks>
/// <b>Grosszügiger als ein Segment lang.</b> Wer pausiert, holt eine
/// Weile nichts ab; ihn nach zwanzig Sekunden abzuwürgen wäre
/// Sparsamkeit an der falschen Stelle. Zwei Minuten reichen für eine
/// Pause und sind kurz genug, dass ein geschlossener Browser nicht
/// stundenlang Platten füllt.
/// </remarks>
public static readonly TimeSpan Geduld = TimeSpan.FromMinutes(2);
/// <summary>Wie viele Ströme gleichzeitig laufen dürfen.</summary>
///
/// <remarks>
/// <b>Weil Umkodieren einen ganzen Rechner beschäftigt.</b> Vier
/// gleichzeitige 4K-Läufe bringen jedes NAS zum Stehen — und dann
/// ruckelt nicht einer, sondern alle vier.
/// </remarks>
private const int HoechstensGleichzeitig = 6;
public int LaufendeAnzahl => _sitzungen.Count;
public Stromsitzung? Sitzung(Guid kennung) => _sitzungen.GetValueOrDefault(kennung)?.Sitzung;
/// <summary>Startet einen Strom und gibt die Sitzung zurück.</summary>
public async Task<Stromsitzung> StartenAsync(
Guid dateiId, Guid benutzer, DeviceProfile profil,
int? hoehe, int? tonspur, double ab, CancellationToken ct = default)
{
AbgelaufeneWegraeumen();
if (_sitzungen.Count >= HoechstensGleichzeitig)
{
throw new InvalidOperationException(
$"Es laufen bereits {HoechstensGleichzeitig} Wiedergaben, die umgerechnet werden müssen. "
+ "Warte, bis eine davon zu Ende ist.");
}
using var bereich = scopes.CreateScope();
var db = bereich.ServiceProvider.GetRequiredService<FlaroContext>();
var datei = await db.MediaFiles.AsNoTracking().FirstOrDefaultAsync(f => f.Id == dateiId, ct)
?? throw new InvalidOperationException("Diese Datei steht nicht mehr in der Mediathek.");
if (!File.Exists(datei.Path))
throw new InvalidOperationException($"„{Path.GetFileName(datei.Path)}“ liegt nicht mehr da.");
var spuren = await db.MediaStreams.AsNoTracking()
.Where(s => s.MediaFileId == dateiId).OrderBy(s => s.Index)
.ToListAsync(ct);
var gelesen = spuren.Select(s => new ProbedStream(
s.Index, s.Kind, s.Codec, s.Language, s.Title,
s.IsDefault, s.IsForced, s.Channels, s.Width, s.Height)).ToList();
var plan = Stromentscheid.Fuer(datei.Container, gelesen, profil, hoehe);
var kennung = Guid.CreateVersion7();
var ordner = Path.Combine(pfade.Transcode, "stroeme", kennung.ToString("N"));
Directory.CreateDirectory(ordner);
var anfrage = new Stromanfrage(
datei.Path, ordner, gelesen, plan, tonspur, Math.Max(0, ab), Hardware());
// **Die tatsächlich benutzte Spur, nicht die gewünschte.** Sonst
// stünde im Menü bei keiner ein Haken, obwohl eine davon läuft — und
// bei einer Spur, die es nicht mehr gibt, bei der falschen.
var sitzung = new Stromsitzung(
kennung, dateiId, benutzer, ordner, Math.Max(0, ab),
plan.Hoehe, HlsArgumente.Tonspur(gelesen, tonspur)?.Index, plan.Art, clock.GetUtcNow());
var prozess = Starten(anfrage, sitzung);
var abbruch = new CancellationTokenSource();
_sitzungen[kennung] = new Eintrag(sitzung, prozess, abbruch);
log.LogInformation(
"Strom {Kennung}: {Art} von {Datei} ab {Ab}s{Hoehe}.",
kennung, plan.Art, Path.GetFileName(datei.Path), sitzung.Ab,
plan.Hoehe is { } h ? $", auf {h}p" : "");
return sitzung;
}
/// <summary>Hält eine Sitzung am Leben — bei jedem abgeholten Segment.</summary>
public void Lebenszeichen(Guid kennung)
{
if (_sitzungen.TryGetValue(kennung, out var eintrag))
eintrag.Sitzung.Gesehen = clock.GetUtcNow();
}
/// <summary>Beendet eine Sitzung und räumt ihren Ordner weg.</summary>
public void Beenden(Guid kennung)
{
if (!_sitzungen.TryRemove(kennung, out var eintrag)) return;
Abraeumen(eintrag);
}
/// <summary>Beendet, was niemand mehr abholt.</summary>
///
/// <remarks>
/// <b>Beim Starten einer neuen Sitzung, nicht in einem eigenen Takt.</b>
/// Ein Hintergrunddienst für einen Handgriff, der ohnehin selten
/// geschieht, wäre ein Faden mehr, der schiefgehen kann.
/// </remarks>
public void AbgelaufeneWegraeumen()
{
var grenze = clock.GetUtcNow() - Geduld;
foreach (var (kennung, eintrag) in _sitzungen)
{
if (eintrag.Sitzung.Gesehen > grenze) continue;
if (!_sitzungen.TryRemove(kennung, out var weg)) continue;
log.LogInformation("Strom {Kennung} wurde seit {Geduld} nicht abgeholt — beendet.",
kennung, Geduld);
Abraeumen(weg);
}
}
/// <summary>Ob die Playlist schon etwas enthält, das sich abspielen lässt.</summary>
///
/// <remarks>
/// <b>Die Datei allein genügt nicht.</b> ffmpeg legt sie an, bevor das
/// erste Segment fertig ist; ein Spieler, der sie dann bekommt, sieht
/// eine leere Liste und gibt auf.
/// </remarks>
public static bool PlaylistBereit(string ordner)
{
var pfad = Path.Combine(ordner, HlsArgumente.Playlist);
if (!File.Exists(pfad)) return false;
try
{
// `ReadAllText` auf einer Datei, in die ffmpeg gerade schreibt:
// deshalb ausdrücklich teilend geöffnet, sonst gäbe es hier je
// nach Zeitpunkt eine Ausnahme statt einer Antwort.
using var strom = new FileStream(
pfad, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
using var leser = new StreamReader(strom);
return leser.ReadToEnd().Contains(".m4s", StringComparison.Ordinal);
}
catch (IOException) { return false; }
}
/// <summary>Welche Beschleunigung benutzt wird.</summary>
///
/// <remarks>
/// <b><c>auto</c> prüft, statt zu behaupten.</b> Liegt kein
/// <c>/dev/dri/renderD128</c> vor — auf einem Mac, in einem Container ohne
/// durchgereichtes Gerät —, läuft es in Software weiter. Langsamer, aber
/// es läuft; ein ffmpeg, das mit „No VA display found" abbricht, wäre für
/// den Zuschauer dasselbe wie ein kaputter Server.
/// </remarks>
private Beschleunigung Hardware()
{
var gewuenscht = Environment.GetEnvironmentVariable("FLARO_HWACCEL")?.Trim().ToLowerInvariant();
return gewuenscht switch
{
"none" or "software" => Beschleunigung.Software,
"vaapi" => Beschleunigung.Vaapi,
_ => File.Exists("/dev/dri/renderD128") ? Beschleunigung.Vaapi : Beschleunigung.Software,
};
}
private Process Starten(Stromanfrage anfrage, Stromsitzung sitzung)
{
var argumente = HlsArgumente.Fuer(anfrage);
var start = new ProcessStartInfo(ffmpeg.Ffmpeg)
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
};
foreach (var a in argumente) start.ArgumentList.Add(a);
var prozess = Process.Start(start)
?? throw new InvalidOperationException($"{ffmpeg.Ffmpeg} liess sich nicht starten.");
// **Der Fehlerkanal wird gelesen, nicht nur umgeleitet.** Läuft die
// Röhre voll, bleibt ffmpeg beim Schreiben stehen — und der Strom
// hört mitten im Film auf, ohne dass irgendwo etwas stünde.
_ = Task.Run(async () =>
{
var text = await prozess.StandardError.ReadToEndAsync();
await prozess.WaitForExitAsync();
sitzung.Beendet = true;
if (prozess.ExitCode == 0) return;
sitzung.Fehler = text.Trim();
log.LogWarning("Strom {Kennung}: ffmpeg endete mit {Code}: {Fehler}",
sitzung.Kennung, prozess.ExitCode, sitzung.Fehler);
});
_ = Task.Run(() => prozess.StandardOutput.ReadToEndAsync());
return prozess;
}
private void Abraeumen(Eintrag eintrag)
{
try
{
if (!eintrag.Prozess.HasExited) eintrag.Prozess.Kill(entireProcessTree: true);
}
catch (Exception fehler) when (fehler is InvalidOperationException or NotSupportedException)
{
log.LogDebug(fehler, "ffmpeg zu {Kennung} liess sich nicht beenden.", eintrag.Sitzung.Kennung);
}
eintrag.Prozess.Dispose();
eintrag.Abbruch.Dispose();
try
{
if (Directory.Exists(eintrag.Sitzung.Ordner))
Directory.Delete(eintrag.Sitzung.Ordner, recursive: true);
}
catch (IOException fehler)
{
log.LogWarning(fehler, "Der Ordner zu Strom {Kennung} blieb liegen.", eintrag.Sitzung.Kennung);
}
}
/// <summary>Beim Herunterfahren bleibt nichts laufen und nichts liegen.</summary>
public void Dispose()
{
foreach (var kennung in _sitzungen.Keys) Beenden(kennung);
// **Auch, was ein Absturz hinterlassen hat.** Nach einem harten Ende
// steht der Ordner noch da; ihn beim nächsten Herunterfahren zu
// übersehen hiesse, dass der Kratzplatz nur wächst.
try
{
var wurzel = Path.Combine(pfade.Transcode, "stroeme");
if (Directory.Exists(wurzel)) Directory.Delete(wurzel, recursive: true);
}
catch (IOException) { }
}
}
@@ -0,0 +1,133 @@
using Flaro.Core.Library;
namespace Flaro.Core.Delivery;
/// <summary>Wie eine Datei in einen laufenden Spieler kommt.</summary>
///
/// <remarks>
/// <b>Nicht zu verwechseln mit <see cref="DeliveryMethod"/>.</b> Dort geht es
/// um eine fertige Datei zum Mitnehmen; hier um einen Strom, der sofort
/// anfängt. Beides hat dieselbe Frage — passt das Gerät zur Datei? — und ganz
/// verschiedene Antworten: eine Datei darf Minuten brauchen, ein Spieler nicht.
/// </remarks>
public enum Stromart
{
/// <summary>Die Datei geht unverändert hinaus, der Browser spielt sie direkt.</summary>
Direkt,
/// <summary>
/// Umgepackt, aber nicht neu gerechnet — mkv nach fMP4, Bild Byte für Byte.
/// </summary>
///
/// <remarks>
/// <b>Das ist der häufigste Fall und der billigste.</b> Eine MKV mit H.264
/// und AAC kann jeder Browser abspielen; nur die Schachtel kennt er nicht.
/// Sie zu wechseln kostet den Server fast nichts.
/// </remarks>
Umpacken,
/// <summary>Das Bild wird neu gerechnet — anderer Codec oder kleinere Auflösung.</summary>
Umkodieren,
}
/// <summary>Warum es nicht beim Durchreichen bleibt.</summary>
public enum Stromgrund
{
PasstSchon,
SchachtelUnbekannt,
BildcodecUnbekannt,
TonUnbekannt,
KleinereAufloesungGewuenscht,
}
/// <summary>Was für diesen Strom zu tun ist.</summary>
///
/// <param name="Hoehe">
/// Auf wie viele Bildzeilen herunterskaliert wird — <c>null</c> heisst:
/// Originalauflösung.
/// </param>
public sealed record Streamplan(
Stromart Art,
IReadOnlyList<Stromgrund> Gruende,
int? Hoehe)
{
public bool BildKopieren => Art != Stromart.Umkodieren;
}
/// <summary>Entscheidet, was ein Strom braucht, damit der Spieler ihn öffnet.</summary>
///
/// <remarks>
/// <para>
/// <b>Getrennt von <see cref="DeliveryDecider"/>, und das ist Absicht.</b> Der
/// entscheidet über eine Datei zum Herunterladen und kennt nur zwei Ausgänge:
/// durchreichen oder umpacken. Ein unbekannter Bildcodec führte dort ebenfalls
/// zum Umpacken — und das half nichts, weil Umpacken das Bild nicht anfasst.
/// Für den Browser war das der Fehler, an dem jede Wiedergabe mit HEVC
/// scheiterte: er bekam eine tadellose MP4, in der ein Codec steckte, den er
/// nicht kennt.
/// </para>
/// <para>
/// <b>Das Bild wird nur angefasst, wenn es sein muss.</b> Neu zu rechnen
/// kostet ein Vielfaches des Umpackens und verliert Bildqualität. Wer eine
/// kleinere Auflösung will, bekommt sie; wer nicht, bekommt das Original.
/// </para>
/// </remarks>
public static class Stromentscheid
{
/// <summary>Die Stufen, die ein Spieler anbieten darf.</summary>
///
/// <remarks>
/// <b>Keine freie Zahl.</b> Vier Stufen decken ab, wofür es Gründe gibt:
/// das Original, zwei Zwischenstufen und eine für die langsame Leitung.
/// Eine beliebige Höhe zuzulassen hiesse, für jede Zahl eine eigene
/// Sitzung zu rechnen.
/// </remarks>
public static readonly IReadOnlyList<int> Stufen = [1080, 720, 480];
/// <param name="hoeheGewuenscht">
/// Die vom Spieler gewählte Stufe, oder <c>null</c> für das Original.
/// </param>
public static Streamplan Fuer(
string? schachtel,
IReadOnlyList<ProbedStream> spuren,
DeviceProfile profil,
int? hoeheGewuenscht = null)
{
var gruende = new List<Stromgrund>();
var bild = spuren.FirstOrDefault(s => s.Kind == MediaStreamKind.Video);
var bildPasst = Passt(bild?.Codec, profil.VideoCodecs);
if (!bildPasst) gruende.Add(Stromgrund.BildcodecUnbekannt);
// **Eine Stufe über der Quelle ist keine Stufe.** Ein 720p-Film in
// „1080p" abzuspielen hiesse, ihn hochzurechnen: mehr Last, mehr
// Daten, kein besseres Bild.
var hoehe = hoeheGewuenscht is { } gewuenscht
&& (bild?.Height is not { } vorhanden || gewuenscht < vorhanden)
? gewuenscht
: (int?)null;
if (hoehe is not null) gruende.Add(Stromgrund.KleinereAufloesungGewuenscht);
if (!bildPasst || hoehe is not null)
return new Streamplan(Stromart.Umkodieren, gruende, hoehe);
var schachtelPasst = Passt(schachtel, profil.Containers);
if (!schachtelPasst) gruende.Add(Stromgrund.SchachtelUnbekannt);
// **Beim Ton genügt eine passende Spur.** Welche gespielt wird,
// entscheidet der Benutzer; dass eine siebenkanalige DTS-Spur daneben
// liegt, ist kein Grund, den ganzen Film anzufassen.
var tonspuren = spuren.Where(s => s.Kind == MediaStreamKind.Audio).ToList();
var tonPasst = tonspuren.Count == 0 || tonspuren.Any(s => Passt(s.Codec, profil.AudioCodecs));
if (!tonPasst) gruende.Add(Stromgrund.TonUnbekannt);
if (!schachtelPasst || !tonPasst)
return new Streamplan(Stromart.Umpacken, gruende, null);
return new Streamplan(Stromart.Direkt, [Stromgrund.PasstSchon], null);
}
/// <summary>Was fehlt, zählt als passend — eine Lücke ist kein Urteil.</summary>
private static bool Passt(string? wert, IReadOnlySet<string> erlaubt) =>
string.IsNullOrWhiteSpace(wert) || erlaubt.Contains(wert);
}
+243
View File
@@ -0,0 +1,243 @@
using System.Globalization;
using Flaro.Core.Delivery;
using Flaro.Core.Library;
namespace Flaro.Media;
/// <summary>Welche Bildbeschleunigung benutzt werden soll.</summary>
public enum Beschleunigung
{
/// <summary>Alles im Hauptprozessor — geht überall, kostet am meisten.</summary>
Software,
/// <summary>VAAPI über <c>/dev/dri</c> — Intel und AMD unter Linux.</summary>
Vaapi,
}
/// <param name="Quelle">Die Datei auf der Platte.</param>
/// <param name="Zielordner">Wohin Playlist und Segmente geschrieben werden.</param>
/// <param name="Tonspur">
/// Welche Tonspur gespielt wird — die Nummer aus der Datei. <c>null</c> nimmt
/// die erste.
/// </param>
/// <param name="Ab">
/// Ab welcher Sekunde. <b>Vor <c>-i</c>, nicht danach</b> — nur dann springt
/// ffmpeg im Vorspann statt die ganze Datei durchzulesen.
/// </param>
public sealed record Stromanfrage(
string Quelle,
string Zielordner,
IReadOnlyList<ProbedStream> Spuren,
Streamplan Plan,
int? Tonspur = null,
double Ab = 0,
Beschleunigung Hardware = Beschleunigung.Software);
/// <summary>Baut die ffmpeg-Zeile für einen laufenden Strom.</summary>
///
/// <remarks>
/// <para>
/// <b>Eine reine Funktion, wie bei <see cref="RemuxArguments"/>.</b> Aus
/// demselben Grund: eine falsche Parameterkombination lässt sich sonst nur
/// gegen einen echten Server entdecken, und dort kostet sie Tage.
/// </para>
/// <para>
/// <b>Ein Rohr für beides.</b> Eine MKV nach fMP4 umzupacken und einen
/// 4K-Film auf 480p herunterzurechnen ist derselbe Weg — einmal mit
/// <c>-c:v copy</c>, einmal mit einem Kodierer. Zwei getrennte Wege hiesse,
/// Sitzungsverwaltung, Segmentierung und Aufräumen zweimal zu haben.
/// </para>
/// <para>
/// <b>Warum HLS und nicht eine fertige Datei.</b> Bisher wurde der ganze Film
/// umgepackt, bevor das erste Bild lief — bei 4K sind das Minuten, in denen
/// nichts geschieht. HLS liefert das erste Segment nach Sekunden.
/// </para>
/// </remarks>
public static class HlsArgumente
{
/// <summary>Wie lang ein Segment ist.</summary>
///
/// <remarks>
/// <b>Sechs Sekunden, wie bei Apple.</b> Kürzer heisst mehr Dateien und
/// mehr Anfragen; länger heisst, dass der Anfang später kommt und jeder
/// Sprung gröber wird.
/// </remarks>
public const int SegmentSekunden = 6;
/// <summary>Wie viele Bilder zwischen zwei Schlüsselbildern liegen.</summary>
///
/// <remarks>
/// <b>Ein Segment muss mit einem Schlüsselbild anfangen.</b> Ohne feste
/// Abstände schneidet ffmpeg dort, wo zufällig eines liegt — und die
/// Segmente werden unterschiedlich lang, was jeden Sprung ungenau macht.
/// </remarks>
private const int BilderJeSekunde = 25;
public const string Playlist = "index.m3u8";
private const string Anfangsstueck = "init.mp4";
private const string Segmentmuster = "seg%05d.m4s";
public static List<string> Fuer(Stromanfrage anfrage)
{
var argumente = new List<string> { "-y", "-loglevel", "error" };
// **Die Beschleunigung steht vor der Eingabe.** Danach wäre sie eine
// Angabe über die Ausgabe und bliebe wirkungslos — ffmpeg sagt dazu
// nichts, es dekodiert einfach im Hauptprozessor weiter.
if (anfrage.Hardware == Beschleunigung.Vaapi && !anfrage.Plan.BildKopieren)
{
argumente.AddRange([
"-hwaccel", "vaapi",
"-hwaccel_output_format", "vaapi",
"-vaapi_device", "/dev/dri/renderD128",
]);
}
if (anfrage.Ab > 0)
{
argumente.Add("-ss");
argumente.Add(anfrage.Ab.ToString("0.###", CultureInfo.InvariantCulture));
}
argumente.Add("-i");
argumente.Add(anfrage.Quelle);
var bild = anfrage.Spuren.FirstOrDefault(s => s.Kind == MediaStreamKind.Video);
var ton = Tonspur(anfrage.Spuren, anfrage.Tonspur);
// **Nur Bild und eine Tonspur.** Mehr trägt eine HLS-Ausgabe nicht,
// ohne in getrennte Listen zu zerfallen; die Spurwahl geschieht
// deshalb über eine neue Sitzung. Untertitel kommen als eigene
// WebVTT-Datei daneben und brauchen den Strom gar nicht anzufassen.
if (bild is not null) { argumente.Add("-map"); argumente.Add($"0:{bild.Index}"); }
if (ton is not null) { argumente.Add("-map"); argumente.Add($"0:{ton.Index}"); }
argumente.AddRange(Bildargumente(anfrage, bild));
argumente.AddRange(Tonargumente(anfrage, ton));
// **Keine Untertitel im Strom.** mov_text in einer fMP4-Segmentierung
// bringt ffmpeg zum Abbruch, und bildbasierte Untertitel passen
// ohnehin nicht hinein.
argumente.Add("-sn");
argumente.AddRange([
"-f", "hls",
"-hls_time", SegmentSekunden.ToString(CultureInfo.InvariantCulture),
// **`event` und nicht `vod`.** Die Liste wächst, während ffmpeg
// schreibt; `vod` behauptete, sie sei schon vollständig, und der
// Spieler bräche am Ende des ersten Segments ab.
"-hls_playlist_type", "event",
// fMP4 statt MPEG-TS: dieselben Segmente, die auch ein
// Browser-MSE-Puffer ohne Umweg annimmt — und HEVC ginge in TS gar
// nicht erst.
"-hls_segment_type", "fmp4",
"-hls_fmp4_init_filename", Anfangsstueck,
"-hls_segment_filename", Path.Combine(anfrage.Zielordner, Segmentmuster),
// Jedes Segment für sich dekodierbar — sonst darf ein Spieler
// nicht mittendrin einsteigen.
"-hls_flags", "independent_segments",
"-start_number", "0",
]);
argumente.Add(Path.Combine(anfrage.Zielordner, Playlist));
return argumente;
}
/// <summary>Welche Tonspur gespielt wird.</summary>
///
/// <remarks>
/// <para>
/// <b>Eine gewünschte, die es nicht gibt, ist keine Fehlermeldung wert.</b>
/// Nach einem erneuten Scan kann sich die Nummerierung verschoben haben;
/// dann ist die erste Spur die vernünftige Antwort, nicht ein stummer Film.
/// </para>
/// <para>
/// <b>Öffentlich, damit die Antwort an den Spieler dieselbe Regel
/// benutzt.</b> Vorher meldete der Server die <i>gewünschte</i> Spur
/// zurück — also meist <c>null</c> —, und im Menü stand bei keiner ein
/// Haken, obwohl eine davon lief.
/// </para>
/// </remarks>
public static ProbedStream? Tonspur(IReadOnlyList<ProbedStream> spuren, int? gewuenscht)
{
var alle = spuren.Where(s => s.Kind == MediaStreamKind.Audio).ToList();
if (alle.Count == 0) return null;
return gewuenscht is { } wunsch
? alle.FirstOrDefault(s => s.Index == wunsch) ?? alle[0]
: alle[0];
}
private static IEnumerable<string> Bildargumente(Stromanfrage anfrage, ProbedStream? bild)
{
if (anfrage.Plan.BildKopieren) return ["-c:v", "copy"];
var schluesselabstand = (SegmentSekunden * BilderJeSekunde).ToString(CultureInfo.InvariantCulture);
if (anfrage.Hardware == Beschleunigung.Vaapi)
{
// **`format=nv12|vaapi,hwupload` vor dem Skalieren.** Ohne diese
// Kette liegt das Bild je nach Quelle mal im Hauptspeicher und mal
// in der Grafikkarte, und `scale_vaapi` nimmt nur das zweite.
var kette = anfrage.Plan.Hoehe is { } h
? $"format=nv12|vaapi,hwupload,scale_vaapi=w=-2:h={h}"
: "format=nv12|vaapi,hwupload";
return [
"-vf", kette,
"-c:v", "h264_vaapi",
"-qp", "23",
"-g", schluesselabstand,
"-bf", "0",
];
}
var weich = new List<string>();
if (anfrage.Plan.Hoehe is { } hoehe)
{
// `-2` statt `-1`: die Breite muss gerade sein, sonst lehnt
// libx264 sie ab. Das hat schon einmal eine Stunde gekostet.
weich.AddRange(["-vf", $"scale=-2:{hoehe}"]);
}
weich.AddRange([
"-c:v", "libx264",
// **`veryfast`, nicht `medium`.** Was in Echtzeit laufen muss,
// darf nicht langsamer rechnen als es abspielt — ein schöneres
// Bild, das stockt, ist kein schöneres Bild.
"-preset", "veryfast",
"-crf", "21",
"-profile:v", "high",
"-level", "4.1",
"-pix_fmt", "yuv420p",
// Feste Schlüsselbildabstände, damit jedes Segment gleich lang wird.
"-g", schluesselabstand,
"-keyint_min", schluesselabstand,
"-sc_threshold", "0",
]);
_ = bild;
return weich;
}
private static IEnumerable<string> Tonargumente(Stromanfrage anfrage, ProbedStream? ton)
{
if (ton is null) return ["-an"];
// **Immer AAC, immer Stereo.** Beim Umpacken wäre Durchreichen
// billiger, aber was hier herauskommt, läuft im Browser: der kennt
// weder EAC3 noch DTS, und eine 5.1-Spur in Stereo zu falten ist
// Aufgabe des Servers, nicht des Lautsprechers.
//
// Genau diese Stelle war der Fehler: beim Umpacken wurde der Ton
// kopiert, weil Apples Profil EAC3 erlaubt — und im Browser blieb der
// Film stumm oder lief gar nicht.
_ = anfrage;
return ["-c:a", "aac", "-ac", "2", "-b:a", "192k", "-ar", "48000"];
}
}
+99
View File
@@ -0,0 +1,99 @@
using System.Diagnostics;
using Microsoft.Extensions.Logging;
namespace Flaro.Media;
/// <summary>Holt eine Untertitelspur als WebVTT aus einer Datei.</summary>
///
/// <remarks>
/// <para>
/// <b>Getrennt vom Strom, und das ist der Punkt.</b> Wandern Untertitel mit
/// in die Segmente, kostet jedes Umschalten eine neue Sitzung — also neues
/// Rechnen, neues Puffern, ein Ruckler. Als eigene Datei daneben hängt der
/// Spieler sie einfach als <c>&lt;track&gt;</c> ein und schaltet in
/// Millisekunden um.
/// </para>
/// <para>
/// <b>Nur Text, kein Bild.</b> PGS und VobSub sind Bilder; aus ihnen WebVTT
/// zu machen hiesse, Schrift zu erkennen. Sie werden gar nicht erst
/// angeboten — siehe <c>StreamEndpoints</c>.
/// </para>
/// </remarks>
public sealed class UntertitelLeser(FfmpegOptions optionen, ILogger<UntertitelLeser> log)
{
/// <summary>Wie lange das Herausziehen höchstens dauern darf.</summary>
///
/// <remarks>
/// Untertitel sind ein paar Dutzend Kilobyte; braucht es dafür länger als
/// eine halbe Minute, stimmt etwas mit der Datei nicht — und ein Spieler,
/// der ewig auf einen Text wartet, ist schlimmer als einer ohne.
/// </remarks>
private static readonly TimeSpan Geduld = TimeSpan.FromSeconds(30);
/// <returns><c>null</c>, wenn es die Spur nicht gibt oder sie kein Text ist.</returns>
public async Task<string?> AlsWebVttAsync(string datei, int spur, CancellationToken ct = default)
{
using var zeitlimit = CancellationTokenSource.CreateLinkedTokenSource(ct);
zeitlimit.CancelAfter(Geduld);
var start = new ProcessStartInfo(optionen.Ffmpeg)
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
};
foreach (var a in new[]
{
"-loglevel", "error",
"-i", datei,
"-map", $"0:{spur}",
"-c:s", "webvtt",
"-f", "webvtt",
// Auf die Standardausgabe statt in eine Datei: es geht um
// Kilobyte, und eine Datei müsste wieder weggeräumt werden.
"pipe:1",
})
{
start.ArgumentList.Add(a);
}
try
{
using var prozess = Process.Start(start)
?? throw new InvalidOperationException($"{optionen.Ffmpeg} liess sich nicht starten.");
var lesen = prozess.StandardOutput.ReadToEndAsync(zeitlimit.Token);
var fehlerLesen = prozess.StandardError.ReadToEndAsync(zeitlimit.Token);
try
{
await prozess.WaitForExitAsync(zeitlimit.Token);
}
catch (OperationCanceledException)
{
try { prozess.Kill(entireProcessTree: true); } catch (InvalidOperationException) { }
throw;
}
var text = await lesen;
if (prozess.ExitCode != 0)
{
log.LogDebug("Untertitelspur {Spur} aus {Datei}: {Fehler}",
spur, Path.GetFileName(datei), (await fehlerLesen).Trim());
return null;
}
// **Eine leere Antwort ist keine Antwort.** ffmpeg gibt bei einer
// Bildspur eine gültige, aber inhaltslose WebVTT-Datei aus; sie
// auszuliefern hiesse, leere Untertitel anzubieten.
return text.Contains("-->", StringComparison.Ordinal) ? text : null;
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
log.LogWarning("Untertitelspur {Spur} aus {Datei} brauchte länger als {Geduld}.",
spur, Path.GetFileName(datei), Geduld);
return null;
}
}
}
+330
View File
@@ -0,0 +1,330 @@
using System.Diagnostics;
using System.Net;
using System.Net.Http.Json;
using Flaro.Api.Endpoints;
namespace Flaro.Api.Tests;
/// <summary>Wiedergabe, die sofort anfängt — von der Anfrage bis zum Segment.</summary>
///
/// <remarks>
/// <b>Mit echtem ffmpeg, wie bei den Lieferungsprüfungen.</b> Ob eine Playlist
/// entsteht und ob darin Segmente stehen, lässt sich nicht behaupten; es muss
/// jemand nachsehen.
/// </remarks>
public class StreamTests
{
private string _bestand = "";
private FlaroServer? _server;
private static string? Werkzeug(string name) =>
(Environment.GetEnvironmentVariable("PATH") ?? "").Split(Path.PathSeparator)
.Select(o => o.Length == 0 ? null : Path.Combine(o, name))
.FirstOrDefault(p => p is not null && File.Exists(p));
[SetUp]
public void Anlegen()
{
if (Werkzeug("ffmpeg") is null) Assert.Ignore("ffmpeg ist auf diesem Rechner nicht installiert.");
_bestand = Directory.CreateDirectory(
Path.Combine(Path.GetTempPath(), "flaro-strom-" + Guid.CreateVersion7().ToString("N"))).FullName;
}
[TearDown]
public void Aufraeumen()
{
_server?.Dispose();
_server = null;
try { if (_bestand.Length > 0) Directory.Delete(_bestand, recursive: true); } catch (IOException) { }
}
/// <summary>Was ein gewöhnlicher Browser kann — kein HEVC, kein EAC3.</summary>
private static StreamRequest Browser(int? hoehe = null, int? ton = null, double? ab = null) => new(
Containers: ["mp4"],
VideoCodecs: ["h264", "avc1", "vp9"],
AudioCodecs: ["aac", "mp3", "opus"],
Height: hoehe, AudioStream: ton, StartSeconds: ab);
/// <summary>Eine MKV mit zwei Tonspuren, die zweite in einem Codec für Apple.</summary>
private string Film(string name, int hoehe = 240, int sekunden = 4)
{
var ziel = Path.Combine(_bestand, $"{name}.mkv");
var start = new ProcessStartInfo(Werkzeug("ffmpeg")!)
{
RedirectStandardError = true,
UseShellExecute = false,
};
foreach (var a in new[]
{
"-y", "-loglevel", "error",
"-f", "lavfi", "-i", $"testsrc=duration={sekunden}:size={hoehe * 4 / 3}x{hoehe}:rate=10",
"-f", "lavfi", "-i", $"sine=frequency=440:duration={sekunden}",
"-f", "lavfi", "-i", $"sine=frequency=880:duration={sekunden}",
"-map", "0:v", "-map", "1:a", "-map", "2:a",
"-metadata:s:a:0", "language=deu", "-metadata:s:a:0", "title=Deutsch",
"-metadata:s:a:1", "language=eng", "-metadata:s:a:1", "title=English",
"-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p",
"-c:a:0", "aac", "-c:a:1", "eac3",
ziel,
})
{
start.ArgumentList.Add(a);
}
using var prozess = Process.Start(start)!;
var fehler = prozess.StandardError.ReadToEnd();
prozess.WaitForExit();
if (prozess.ExitCode != 0) throw new InvalidOperationException(fehler);
return ziel;
}
private async Task<(HttpClient Client, MediaFileResponse Datei)> Eingelesen()
{
var server = _server = new FlaroServer();
var admin = await server.SetUpAndSignIn();
var client = server.AlsAngemeldeter(admin.Token);
var angelegt = await client.PostAsJsonAsync("/api/v1/libraries",
new CreateLibraryRequest("Prüfbestand", "Movies", _bestand));
angelegt.EnsureSuccessStatusCode();
var bibliothek = (await angelegt.Content.ReadFromJsonAsync<LibraryResponse>())!;
(await client.PostAsync($"/api/v1/libraries/{bibliothek.Id}/scan", null)).EnsureSuccessStatusCode();
for (var i = 0; i < 200; i++)
{
var stand = await client.GetFromJsonAsync<ScanStatus>($"/api/v1/libraries/{bibliothek.Id}/scan");
if (stand!.State == "finished") break;
await Task.Delay(50);
}
var filme = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies");
Assert.That(filme!.Items, Is.Not.Empty, "Der Scan hat nichts gefunden");
return (client, filme.Items[0].Files.Single());
}
private static async Task<StreamResponse> Strom(
HttpClient client, Guid datei, StreamRequest anfrage)
{
var antwort = await client.PostAsJsonAsync($"/api/v1/files/{datei}/stream", anfrage);
antwort.EnsureSuccessStatusCode();
return (await antwort.Content.ReadFromJsonAsync<StreamResponse>())!;
}
// ------------------------------------------------------------------
/// <summary>Eine MKV wird für den Browser umgepackt, nicht neu gerechnet.</summary>
[Test]
public async Task Eine_mkv_wird_fuer_den_Browser_umgepackt()
{
Film("Umpacken (2020)");
var (client, datei) = await Eingelesen();
var strom = await Strom(client, datei.Id, Browser());
Assert.Multiple(() =>
{
Assert.That(strom.Method, Is.EqualTo("remux"),
"H.264 kennt jeder Browser — nur die Schachtel nicht");
Assert.That(strom.Height, Is.Null, "ohne Wunsch bleibt die Auflösung, wie sie ist");
Assert.That(strom.AudioStreams, Has.Count.EqualTo(2));
});
}
/// <summary>Die Playlist kommt, sobald das erste Segment da ist.</summary>
///
/// <remarks>
/// <b>Und nicht als 204.</b> Erst stand hier eine leere Antwort, solange
/// noch nichts fertig war — hls.js hielt das für einen Fehler und gab auf.
/// Gewartet wird jetzt serverseitig.
/// </remarks>
[Test]
public async Task Die_Playlist_enthaelt_Segmente()
{
Film("Playlist (2020)");
var (client, datei) = await Eingelesen();
var strom = await Strom(client, datei.Id, Browser());
var antwort = await client.GetAsync(strom.Url);
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.OK));
var text = await antwort.Content.ReadAsStringAsync();
Assert.Multiple(() =>
{
Assert.That(text, Does.StartWith("#EXTM3U"));
Assert.That(text, Does.Contain(".m4s"), "ohne Segment ist die Liste wertlos");
Assert.That(text, Does.Contain("#EXT-X-MAP:URI=\"init.mp4\""));
});
// Das Anfangsstück und das erste Segment müssen auch abholbar sein.
var anfang = await client.GetAsync($"/api/v1/stream/{strom.SessionId}/init.mp4");
var erstes = await client.GetAsync($"/api/v1/stream/{strom.SessionId}/seg00000.m4s");
Assert.Multiple(() =>
{
Assert.That(anfang.StatusCode, Is.EqualTo(HttpStatusCode.OK));
Assert.That(erstes.StatusCode, Is.EqualTo(HttpStatusCode.OK));
});
}
/// <summary>Ein unbekannter Bildcodec führt zum Rechnen, nicht zum Umpacken.</summary>
///
/// <remarks>
/// <b>Das war der Fehler, an dem jede Wiedergabe scheiterte.</b> Der
/// Lieferweg für Downloads antwortete auch hier mit „umpacken" — und
/// Umpacken fasst das Bild nicht an. Der Browser bekam eine tadellose MP4
/// mit einem Codec, den er nicht kennt.
/// </remarks>
[Test]
public async Task Ein_unbekannter_Bildcodec_wird_gerechnet()
{
Film("Rechnen (2020)");
var (client, datei) = await Eingelesen();
// Ein Gerät, das H.264 **nicht** kann — der kürzeste Weg zu „muss gerechnet werden".
var strom = await Strom(client, datei.Id, new StreamRequest(
Containers: ["mp4"], VideoCodecs: ["vp9"], AudioCodecs: ["aac"]));
Assert.That(strom.Method, Is.EqualTo("transcode"));
}
/// <summary>Eine kleinere Auflösung wird wirklich kleiner.</summary>
[Test]
public async Task Eine_kleinere_Stufe_wird_herunterskaliert()
{
Film("Gross (2020)", hoehe: 720);
var (client, datei) = await Eingelesen();
var strom = await Strom(client, datei.Id, Browser(hoehe: 480));
Assert.Multiple(() =>
{
Assert.That(strom.Method, Is.EqualTo("transcode"));
Assert.That(strom.Height, Is.EqualTo(480));
});
}
/// <summary>Hochrechnen ist keine Stufe.</summary>
[Test]
public async Task Eine_Stufe_ueber_der_Quelle_bleibt_beim_Umpacken()
{
Film("Klein (2020)", hoehe: 240);
var (client, datei) = await Eingelesen();
var strom = await Strom(client, datei.Id, Browser(hoehe: 1080));
Assert.Multiple(() =>
{
Assert.That(strom.Height, Is.Null);
Assert.That(strom.Method, Is.EqualTo("remux"),
"240p auf 1080p zu rechnen kostet Last für kein besseres Bild");
});
}
/// <summary>Der Spieler erfährt, welche Tonspur wirklich läuft.</summary>
///
/// <remarks>
/// <b>Nicht die gewünschte.</b> Vorher meldete der Server zurück, was
/// gefragt worden war — meist <c>null</c> —, und im Menü stand bei keiner
/// Spur ein Haken, obwohl eine davon lief.
/// </remarks>
[Test]
public async Task Der_Strom_nennt_die_benutzte_Tonspur()
{
Film("Tonwahl (2020)");
var (client, datei) = await Eingelesen();
var ohneWunsch = await Strom(client, datei.Id, Browser());
var mitWunsch = await Strom(client, datei.Id, Browser(ton: 2));
var unsinn = await Strom(client, datei.Id, Browser(ton: 99));
Assert.Multiple(() =>
{
Assert.That(ohneWunsch.AudioStream, Is.EqualTo(1), "ohne Wunsch die erste");
Assert.That(mitWunsch.AudioStream, Is.EqualTo(2));
Assert.That(unsinn.AudioStream, Is.EqualTo(1),
"eine Spur, die es nicht gibt, macht den Film nicht stumm");
});
}
/// <summary>Der Einstiegspunkt kommt zurück, damit der Spieler richtig zählt.</summary>
///
/// <remarks>
/// <b>Sonst ginge jeder gemeldete Stand um den Vorlauf daneben.</b> Der
/// Strom fängt bei null an, der Film bei Minute 30.
/// </remarks>
[Test]
public async Task Der_Einstiegspunkt_steht_in_der_Antwort()
{
Film("Sprung (2020)", sekunden: 4);
var (client, datei) = await Eingelesen();
var strom = await Strom(client, datei.Id, Browser(ab: 2));
Assert.That(strom.StartSeconds, Is.EqualTo(2));
}
/// <summary>Schliessen räumt auf — sonst füllt sich der Kratzplatz.</summary>
[Test]
public async Task Schliessen_raeumt_die_Sitzung_weg()
{
Film("Aufraeumen (2020)");
var (client, datei) = await Eingelesen();
var strom = await Strom(client, datei.Id, Browser());
(await client.GetAsync(strom.Url)).EnsureSuccessStatusCode();
var weg = await client.DeleteAsync($"/api/v1/stream/{strom.SessionId}");
var danach = await client.GetAsync(strom.Url);
Assert.Multiple(() =>
{
Assert.That(weg.StatusCode, Is.EqualTo(HttpStatusCode.NoContent));
Assert.That(danach.StatusCode, Is.EqualTo(HttpStatusCode.NotFound),
"eine beendete Sitzung gibt es nicht mehr");
});
}
/// <summary>Ein Pfad aus der Anfrage darf nie ans Dateisystem durchgereicht werden.</summary>
[Test]
public async Task Aus_der_Sitzung_kommt_man_nicht_heraus()
{
Film("Sicher (2020)");
var (client, datei) = await Eingelesen();
var strom = await Strom(client, datei.Id, Browser());
var hinaus = await client.GetAsync($"/api/v1/stream/{strom.SessionId}/..%2F..%2Fflaro.db");
var fremd = await client.GetAsync($"/api/v1/stream/{strom.SessionId}/index.m3u8x");
Assert.Multiple(() =>
{
Assert.That(hinaus.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
Assert.That(fremd.StatusCode, Is.EqualTo(HttpStatusCode.NotFound));
});
}
/// <summary>Ohne Anmeldung kommt niemand an einen Strom.</summary>
[Test]
public async Task Ohne_Anmeldung_gibt_es_keinen_Strom()
{
Film("Zutritt (2020)");
var (client, datei) = await Eingelesen();
var strom = await Strom(client, datei.Id, Browser());
var ohne = _server!.OhneGedaechtnis();
var antwort = await ohne.GetAsync(strom.Url);
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
}
/// <summary>Eine Sitzung, die es nicht gibt, ist eine 404 und kein Absturz.</summary>
[Test]
public async Task Eine_unbekannte_Sitzung_ist_nicht_gefunden()
{
Film("Unbekannt (2020)");
var (client, _) = await Eingelesen();
var antwort = await client.GetAsync($"/api/v1/stream/{Guid.CreateVersion7()}/index.m3u8");
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.NotFound));
}
}
@@ -0,0 +1,267 @@
using Flaro.Core.Delivery;
using Flaro.Core.Library;
namespace Flaro.Media.Tests;
/// <summary>Die ffmpeg-Zeile für einen laufenden Strom, ohne sie auszuführen.</summary>
public class HlsArgumenteTests
{
private static ProbedStream Bild(string codec = "h264", int hoehe = 1080) =>
new(0, MediaStreamKind.Video, codec, null, null, true, false, null, hoehe * 16 / 9, hoehe);
private static ProbedStream Ton(int i, string codec = "eac3", int kanaele = 6) =>
new(i, MediaStreamKind.Audio, codec, "deu", null, false, false, kanaele, null, null);
private static ProbedStream Text(int i) =>
new(i, MediaStreamKind.Subtitle, "subrip", "deu", null, false, false, null, null, null);
/// <summary>Was ein gewöhnlicher Browser kann.</summary>
private static readonly DeviceProfile Browser = new(
Containers: new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "mp4", "webm" },
VideoCodecs: new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "h264", "avc1", "vp9" },
AudioCodecs: new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "aac", "mp3", "opus" });
private static string Zeile(
IReadOnlyList<ProbedStream> spuren, string schachtel = "mkv",
int? hoehe = null, int? tonspur = null, double ab = 0,
Beschleunigung hardware = Beschleunigung.Software)
{
var plan = Stromentscheid.Fuer(schachtel, spuren, Browser, hoehe);
return string.Join(' ', HlsArgumente.Fuer(new Stromanfrage(
"/medien/film.mkv", "/kratz/sitzung", spuren, plan, tonspur, ab, hardware)));
}
// ------------------------------------------------------------------
// Umpacken: das Bild bleibt unberührt
// ------------------------------------------------------------------
/// <summary>Eine MKV mit H.264 wird umgepackt, nicht neu gerechnet.</summary>
///
/// <remarks>
/// <b>Der häufigste Fall und der billigste.</b> Der Browser kennt die
/// Schachtel nicht, das Bild darin sehr wohl.
/// </remarks>
[Test]
public void Eine_mkv_mit_bekanntem_Bild_wird_nur_umgepackt()
{
var zeile = Zeile([Bild(), Ton(1, "aac")]);
Assert.Multiple(() =>
{
Assert.That(zeile, Does.Contain("-c:v copy"));
Assert.That(zeile, Does.Not.Contain("libx264"));
Assert.That(zeile, Does.Contain("-f hls"));
});
}
/// <summary>Der Ton wird immer nach AAC gerechnet — das war der Fehler.</summary>
///
/// <remarks>
/// <b>Hier lag der Grund, aus dem gar nichts lief.</b> Beim Umpacken für
/// den Download wird der Ton kopiert, weil Apples Profil EAC3 erlaubt. Der
/// Browser bekam dieselbe Entscheidung — und mit ihr eine Datei, deren Ton
/// er nicht öffnen kann.
/// </remarks>
[Test]
public void Der_Ton_wird_immer_nach_AAC_gerechnet()
{
var zeile = Zeile([Bild(), Ton(1, "eac3")]);
Assert.Multiple(() =>
{
Assert.That(zeile, Does.Contain("-c:a aac"));
Assert.That(zeile, Does.Contain("-ac 2"), "5.1 wird auf Stereo gefaltet");
Assert.That(zeile, Does.Not.Contain("-c:a copy"));
});
}
// ------------------------------------------------------------------
// Umkodieren: unbekanntes Bild oder kleinere Auflösung
// ------------------------------------------------------------------
/// <summary>HEVC kann kein Browser — da hilft Umpacken nichts.</summary>
[Test]
public void Ein_unbekannter_Bildcodec_wird_neu_gerechnet()
{
var zeile = Zeile([Bild("hevc"), Ton(1, "aac")]);
Assert.Multiple(() =>
{
Assert.That(zeile, Does.Contain("-c:v libx264"));
Assert.That(zeile, Does.Not.Contain("-c:v copy"));
});
}
/// <summary>Ein 4K-Film soll sich auch in 480p ansehen lassen.</summary>
[Test]
public void Eine_kleinere_Aufloesung_wird_herunterskaliert()
{
var zeile = Zeile([Bild("h264", 2160), Ton(1, "aac")], hoehe: 480);
Assert.Multiple(() =>
{
Assert.That(zeile, Does.Contain("-vf scale=-2:480"),
"-2 und nicht -1: die Breite muss gerade sein, sonst lehnt libx264 ab");
Assert.That(zeile, Does.Contain("-c:v libx264"));
});
}
/// <summary>Hochrechnen ist keine Stufe.</summary>
///
/// <remarks>
/// Ein 720p-Film in „1080p" kostet mehr Last und mehr Daten für kein
/// besseres Bild. Die Stufe wird dann stillschweigend übergangen.
/// </remarks>
[Test]
public void Eine_Stufe_ueber_der_Quelle_wird_nicht_hochgerechnet()
{
var plan = Stromentscheid.Fuer("mp4", [Bild("h264", 720)], Browser, 1080);
Assert.Multiple(() =>
{
Assert.That(plan.Hoehe, Is.Null);
Assert.That(plan.Art, Is.EqualTo(Stromart.Direkt));
});
}
// ------------------------------------------------------------------
// Spurwahl, Springen, Hardware
// ------------------------------------------------------------------
[Test]
public void Die_gewaehlte_Tonspur_wird_abgebildet()
{
var zeile = Zeile([Bild(), Ton(1), Ton(2), Ton(3)], tonspur: 3);
Assert.Multiple(() =>
{
Assert.That(zeile, Does.Contain("-map 0:0"));
Assert.That(zeile, Does.Contain("-map 0:3"));
Assert.That(zeile, Does.Not.Contain("-map 0:1"));
});
}
/// <summary>Eine Spur, die es nicht gibt, macht den Film nicht stumm.</summary>
[Test]
public void Eine_unbekannte_Tonspur_faellt_auf_die_erste_zurueck()
{
var zeile = Zeile([Bild(), Ton(1), Ton(2)], tonspur: 99);
Assert.That(zeile, Does.Contain("-map 0:1"));
}
/// <summary>Der Sprungpunkt steht vor der Eingabe.</summary>
///
/// <remarks>
/// <b>Hinter <c>-i</c> wäre es ein genauer, aber langsamer Sprung:</b>
/// ffmpeg läse die Datei bis dorthin durch. Bei Minute 90 eines Films
/// dauert das länger als das Ansehen.
/// </remarks>
[Test]
public void Der_Sprungpunkt_steht_vor_der_Eingabe()
{
var argumente = HlsArgumente.Fuer(new Stromanfrage(
"/medien/film.mkv", "/kratz/sitzung",
[Bild(), Ton(1)],
Stromentscheid.Fuer("mkv", [Bild(), Ton(1)], Browser),
Ab: 930.5));
var ss = argumente.IndexOf("-ss");
var i = argumente.IndexOf("-i");
Assert.Multiple(() =>
{
Assert.That(ss, Is.GreaterThanOrEqualTo(0));
Assert.That(ss, Is.LessThan(i));
Assert.That(argumente[ss + 1], Is.EqualTo("930.5"),
"mit Punkt, nicht mit Komma — ffmpeg liest keine deutsche Schreibweise");
});
}
[Test]
public void Ohne_Sprungpunkt_steht_kein_ss_in_der_Zeile()
{
Assert.That(Zeile([Bild(), Ton(1)]), Does.Not.Contain("-ss"));
}
/// <summary>Die Beschleunigung gehört vor die Eingabe, sonst wirkt sie nicht.</summary>
[Test]
public void Vaapi_steht_vor_der_Eingabe_und_nur_beim_Rechnen()
{
var mitRechnen = HlsArgumente.Fuer(new Stromanfrage(
"/medien/film.mkv", "/kratz/sitzung",
[Bild("hevc"), Ton(1)],
Stromentscheid.Fuer("mkv", [Bild("hevc"), Ton(1)], Browser),
Hardware: Beschleunigung.Vaapi));
Assert.Multiple(() =>
{
Assert.That(mitRechnen.IndexOf("-hwaccel"), Is.LessThan(mitRechnen.IndexOf("-i")));
Assert.That(string.Join(' ', mitRechnen), Does.Contain("h264_vaapi"));
// Beim reinen Umpacken wird nichts dekodiert — die Grafikkarte
// hätte dabei nichts zu tun.
Assert.That(Zeile([Bild(), Ton(1)], hardware: Beschleunigung.Vaapi),
Does.Not.Contain("-hwaccel"));
});
}
// ------------------------------------------------------------------
// Form der Ausgabe
// ------------------------------------------------------------------
[Test]
public void Die_Segmente_sind_unabhaengig_und_die_Liste_waechst()
{
var zeile = Zeile([Bild(), Ton(1)]);
Assert.Multiple(() =>
{
Assert.That(zeile, Does.Contain("-hls_playlist_type event"),
"vod behauptete, die Liste sei vollständig — der Spieler brach nach dem ersten Segment ab");
Assert.That(zeile, Does.Contain("-hls_segment_type fmp4"));
Assert.That(zeile, Does.Contain("-hls_flags independent_segments"));
Assert.That(zeile, Does.EndWith(Path.Combine("/kratz/sitzung", "index.m3u8")));
});
}
/// <summary>Untertitel bleiben draussen — sie kommen als eigene Datei.</summary>
[Test]
public void Untertitel_wandern_nicht_in_den_Strom()
{
var zeile = Zeile([Bild(), Ton(1), Text(2)]);
Assert.Multiple(() =>
{
Assert.That(zeile, Does.Contain("-sn"));
Assert.That(zeile, Does.Not.Contain("-map 0:2"));
});
}
/// <summary>Beim Rechnen wird jedes Segment gleich lang.</summary>
[Test]
public void Beim_Rechnen_stehen_die_Schluesselbilder_fest()
{
var zeile = Zeile([Bild("hevc"), Ton(1)]);
Assert.Multiple(() =>
{
Assert.That(zeile, Does.Contain("-g 150"), "6 Sekunden × 25 Bilder");
Assert.That(zeile, Does.Contain("-keyint_min 150"));
Assert.That(zeile, Does.Contain("-sc_threshold 0"));
});
}
/// <summary>Eine Datei ohne Ton bleibt eine Datei ohne Ton.</summary>
[Test]
public void Ohne_Tonspur_wird_nichts_erfunden()
{
var zeile = Zeile([Bild()]);
Assert.Multiple(() =>
{
Assert.That(zeile, Does.Contain("-an"));
Assert.That(zeile, Does.Not.Contain("-c:a aac"));
});
}
}
@@ -0,0 +1,225 @@
using System.Diagnostics;
using Flaro.Core.Delivery;
using Flaro.Core.Library;
namespace Flaro.Media.Tests;
/// <summary>Dieselbe Zeile, diesmal wirklich ausgeführt.</summary>
///
/// <remarks>
/// <para>
/// <b>Weil die reine Prüfung nur die Form zeigt.</b> Sie sagt, dass
/// <c>-hls_segment_type fmp4</c> in der Zeile steht — nicht, dass ffmpeg die
/// Kombination annimmt. Genau dort sitzen die teuren Fehler: eine Option, die
/// für sich richtig aussieht und in Verbindung mit einer anderen zum Abbruch
/// führt.
/// </para>
/// <para>
/// <b>Übersprungen, wo kein ffmpeg liegt.</b> Eine Prüfung, die auf einem
/// Rechner ohne Werkzeug rot wird, sagt nichts über den Code.
/// </para>
/// </remarks>
public class HlsLaufTests
{
private string _ordner = "";
[SetUp]
public void Anlegen()
{
if (!Testdateien.FfmpegVorhanden) Assert.Ignore("Ohne ffmpeg nicht prüfbar.");
_ordner = Directory.CreateDirectory(
Path.Combine(Path.GetTempPath(), "flaro-hls-" + Guid.CreateVersion7().ToString("N"))).FullName;
}
[TearDown]
public void Aufraeumen()
{
try { Directory.Delete(_ordner, recursive: true); } catch (IOException) { }
}
private static readonly DeviceProfile Browser = new(
Containers: new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "mp4" },
VideoCodecs: new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "h264", "avc1" },
AudioCodecs: new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "aac", "mp3" });
/// <summary>Führt die gebaute Zeile aus und gibt den Zielordner zurück.</summary>
private string Laufen(string quelle, int? hoehe = null, int? tonspur = null, double ab = 0)
{
var spuren = Spuren(quelle);
var ziel = Directory.CreateDirectory(Path.Combine(_ordner, "aus" + Guid.NewGuid().ToString("N")[..6])).FullName;
var plan = Stromentscheid.Fuer(Path.GetExtension(quelle).TrimStart('.'), spuren, Browser, hoehe);
var argumente = HlsArgumente.Fuer(new Stromanfrage(quelle, ziel, spuren, plan, tonspur, ab));
var start = new ProcessStartInfo(Testdateien.Suchen("ffmpeg")!)
{
RedirectStandardError = true,
UseShellExecute = false,
};
foreach (var a in argumente) start.ArgumentList.Add(a);
using var prozess = Process.Start(start)!;
var fehler = prozess.StandardError.ReadToEnd();
prozess.WaitForExit();
Assert.That(prozess.ExitCode, Is.Zero,
$"ffmpeg lehnte die gebaute Zeile ab:\n{string.Join(' ', argumente)}\n\n{fehler}");
return ziel;
}
/// <summary>Was wirklich in der Datei steht — nicht, was wir annehmen.</summary>
private static List<ProbedStream> Spuren(string datei)
{
var start = new ProcessStartInfo(Testdateien.Suchen("ffprobe")!)
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
};
foreach (var a in new[]
{
"-v", "error", "-show_entries", "stream=index,codec_type,codec_name,height",
"-of", "csv=p=0", datei,
})
{
start.ArgumentList.Add(a);
}
using var prozess = Process.Start(start)!;
var ausgabe = prozess.StandardOutput.ReadToEnd();
prozess.WaitForExit();
var spuren = new List<ProbedStream>();
foreach (var zeile in ausgabe.Split('\n', StringSplitOptions.RemoveEmptyEntries))
{
var teile = zeile.Trim().Split(',');
if (teile.Length < 3) continue;
MediaStreamKind? art = teile[2] switch
{
"video" => MediaStreamKind.Video,
"audio" => MediaStreamKind.Audio,
"subtitle" => MediaStreamKind.Subtitle,
// Anhänge und Datenströme sind hier nichts, womit sich rechnen
// liesse — sie zu einer Art zu erklären wäre eine Erfindung.
_ => null,
};
if (art is null) continue;
int? hoehe = teile.Length > 3 && int.TryParse(teile[3], out var h) ? h : null;
spuren.Add(new ProbedStream(
int.Parse(teile[0]), art.Value, teile[1], null, null, false, false, null, null, hoehe));
}
return spuren;
}
// ------------------------------------------------------------------
/// <summary>Eine MKV wird umgepackt, und es entstehen abspielbare Segmente.</summary>
[Test]
public void Eine_mkv_wird_zu_Playlist_und_Segmenten()
{
var quelle = Path.Combine(_ordner, "film.mkv");
Testdateien.MitZweiTonspuren(quelle);
var ziel = Laufen(quelle);
var playlist = Path.Combine(ziel, "index.m3u8");
Assert.Multiple(() =>
{
Assert.That(File.Exists(playlist), "die Liste muss da sein");
Assert.That(File.Exists(Path.Combine(ziel, "init.mp4")),
"fMP4 braucht ein Anfangsstück, sonst kann kein Spieler einsteigen");
Assert.That(Directory.GetFiles(ziel, "*.m4s"), Is.Not.Empty);
});
var text = File.ReadAllText(playlist);
Assert.Multiple(() =>
{
Assert.That(text, Does.StartWith("#EXTM3U"));
Assert.That(text, Does.Contain("#EXT-X-MAP:URI=\"init.mp4\""));
});
}
/// <summary>Was herauskommt, ist H.264 und AAC — und nichts anderes.</summary>
///
/// <remarks>
/// <b>Das ist der eigentliche Punkt.</b> Die Quelle hat eine Tonspur, die
/// kein Browser kennt; wäre sie kopiert worden, bliebe der Film stumm —
/// und genau so war es vorher.
/// </remarks>
[Test]
public void Aus_untauglichem_Ton_wird_AAC()
{
var quelle = Path.Combine(_ordner, "dts.mkv");
Testdateien.MitUntauglichemTon(quelle);
var ziel = Laufen(quelle, tonspur: 2); // die DTS-Spur ausdrücklich wählen
var codecs = Spuren(Path.Combine(ziel, "index.m3u8")).Select(s => s.Codec).ToList();
Assert.Multiple(() =>
{
Assert.That(codecs, Does.Contain("h264"));
Assert.That(codecs, Does.Contain("aac"));
Assert.That(codecs, Does.Not.Contain("dts"));
});
}
/// <summary>Herunterskalieren ändert die Bildhöhe wirklich.</summary>
[Test]
public void Herunterskalieren_aendert_die_Hoehe()
{
var quelle = Path.Combine(_ordner, "gross.mkv");
Gross(quelle);
var ziel = Laufen(quelle, hoehe: 120);
var bild = Spuren(Path.Combine(ziel, "index.m3u8"))
.First(s => s.Kind == MediaStreamKind.Video);
Assert.That(bild.Height, Is.EqualTo(120));
}
/// <summary>Ein Sprung mitten hinein liefert ebenfalls Segmente.</summary>
[Test]
public void Ein_Sprungpunkt_liefert_den_Rest_des_Films()
{
var quelle = Path.Combine(_ordner, "lang.mkv");
Lang(quelle);
var ziel = Laufen(quelle, ab: 4);
Assert.That(Directory.GetFiles(ziel, "*.m4s"), Is.Not.Empty);
}
private static void Gross(string ziel) => Werkzeug(
[
"-y", "-loglevel", "error",
"-f", "lavfi", "-i", "testsrc=duration=1:size=640x480:rate=10",
"-f", "lavfi", "-i", "sine=frequency=440:duration=1",
"-c:v", "libx264", "-preset", "ultrafast", "-c:a", "aac",
ziel,
]);
private static void Lang(string ziel) => Werkzeug(
[
"-y", "-loglevel", "error",
"-f", "lavfi", "-i", "testsrc=duration=8:size=160x120:rate=10",
"-f", "lavfi", "-i", "sine=frequency=440:duration=8",
"-c:v", "libx264", "-preset", "ultrafast", "-c:a", "aac",
ziel,
]);
private static void Werkzeug(string[] argumente)
{
var start = new ProcessStartInfo(Testdateien.Suchen("ffmpeg")!)
{
RedirectStandardError = true,
UseShellExecute = false,
};
foreach (var a in argumente) start.ArgumentList.Add(a);
using var prozess = Process.Start(start)!;
var fehler = prozess.StandardError.ReadToEnd();
prozess.WaitForExit();
if (prozess.ExitCode != 0) throw new InvalidOperationException(fehler);
}
}
+44
View File
@@ -494,6 +494,50 @@ export const favorit = (id: string, wert: boolean) =>
export const downloadAdresse = (datei: string) => `/api/v1/files/${datei}/download`;
/* ---------- Strom ---------- */
/** Eine wählbare Spur, so wie sie im Menü steht. */
export type Spurauskunft = {
index: number; label: string; language: string | null; isDefault: boolean;
};
export type Stromauskunft = {
sessionId: string;
url: string;
/** „direct", „remux" oder „transcode". */
method: 'direct' | 'remux' | 'transcode';
/** Ab welcher Sekunde des Films dieser Strom beginnt — der Spieler zählt ab null. */
startSeconds: number;
height: number | null;
audioStream: number | null;
audioStreams: Spurauskunft[];
subtitleStreams: Spurauskunft[];
};
export type Stromwunsch = {
containers: string[]; videoCodecs: string[]; audioCodecs: string[];
height?: number | null;
audioStream?: number | null;
startSeconds?: number;
};
/** Legt einen Strom an, der sofort anfängt.
*
* **Das Gerät sagt, was es kann.** Ohne diese Angabe nimmt der Server Apples
* Profil an — und das erlaubt HEVC und EAC3, von denen kein Browser etwas
* öffnet. */
export const stromStarten = (datei: string, wunsch: Stromwunsch) =>
anfrage<Stromauskunft>(`/files/${datei}/stream`, {
method: 'POST', body: JSON.stringify(wunsch),
});
/** Beendet einen Strom und räumt seinen Kratzplatz weg. */
export const stromBeenden = (sitzung: string) =>
anfrage<void>(`/stream/${sitzung}`, { method: 'DELETE' });
export const untertitelAdresse = (datei: string, spur: number) =>
`/api/v1/files/${datei}/subtitles/${spur}.vtt`;
/* ---------- Verwaltung ---------- */
export const bibliotheken = () => anfrage<Bibliothek[]>('/libraries');
+10
View File
@@ -0,0 +1,10 @@
/* Die schlanke Fassung von hls.js bringt keine eigenen Typen mit.
*
* **Sie hat dieselbe Schnittstelle** — ihr fehlen nur Fähigkeiten, die wir
* nicht benutzen: getrennte Tonspuren im Strom, Untertitel in der Playlist,
* Kopierschutz. Die Typen der vollständigen Fassung zu übernehmen ist deshalb
* richtig und nicht bloss bequem. */
declare module 'hls.js/light' {
export * from 'hls.js';
export { default } from 'hls.js';
}
+15
View File
@@ -50,6 +50,21 @@ export const icons = {
+ '<path d="M10 16.5 14.5 12 10 7.5"/><path d="M14.5 12H4"/>'),
schliessen: rahmen('<path d="M6 6l12 12M18 6 6 18"/>'),
stern: rahmen('<path d="m12 3.6 2.6 5.3 5.9.9-4.3 4.1 1 5.8-5.2-2.7-5.2 2.7 1-5.8L3.5 9.8l5.9-.9z"/>'),
/* --- Spieler -------------------------------------------------------- */
pause: rahmen('<rect x="7" y="5" width="3.5" height="14" rx="1"/>'
+ '<rect x="13.5" y="5" width="3.5" height="14" rx="1"/>', true),
ton: rahmen('<path d="M4 9.5h3.5L12 5.5v13L7.5 14.5H4z"/>'
+ '<path d="M16 9a4.5 4.5 0 0 1 0 6"/><path d="M18.5 6.5a8 8 0 0 1 0 11"/>'),
stummGeschaltet: rahmen('<path d="M4 9.5h3.5L12 5.5v13L7.5 14.5H4z"/>'
+ '<path d="m16.5 9.5 5 5M21.5 9.5l-5 5"/>'),
vollbild: rahmen('<path d="M4 9V5h4"/><path d="M20 9V5h-4"/>'
+ '<path d="M4 15v4h4"/><path d="M20 15v4h-4"/>'),
untertitel: rahmen('<rect x="3" y="5" width="18" height="14" rx="2"/>'
+ '<path d="M7 14.5h4"/><path d="M13 14.5h4"/>'),
zahnrad: rahmen('<circle cx="12" cy="12" r="3.2"/>'
+ '<path d="M12 2.8v2.4M12 18.8v2.4M4.5 7.5l2.1 1.2M17.4 15.3l2.1 1.2'
+ 'M4.5 16.5l2.1-1.2M17.4 8.7l2.1-1.2"/>'),
} as const;
export type IconName = keyof typeof icons;
+673
View File
@@ -0,0 +1,673 @@
import type HlsTyp from 'hls.js';
import {
stromStarten, stromBeenden, untertitelAdresse, fortschritt,
type Stromauskunft, type Spurauskunft,
} from './api';
import { dauer, knopf, meldung } from './util';
import { icons, icon } from './icons';
/* ---------- Was der Browser kann ---------- */
/** Fragt den Browser, statt es zu raten.
*
* **Vorher bekam er Apples Geräteprofil**, und das erlaubt HEVC, AC3 und
* EAC3 — von denen Chrome keines öffnet. Der Server packte die Datei
* pflichtschuldig in eine MP4 um, liess Bild und Ton aber unberührt, und
* heraus kam eine tadellose Datei, mit der der Browser nichts anfangen
* konnte. Genau das war „der Player funktioniert nicht".
*
* `canPlayType` antwortet mit `"probably"`, `"maybe"` oder `""`. Alles ausser
* der leeren Zeichenkette zählt: `"maybe"` heisst „der Codec ist bekannt, über
* das Profil sage ich nichts" — und ein Film, der vielleicht läuft, ist
* besser als einer, der sicher umgerechnet wird.
*/
export function geraeteprofil(): {
containers: string[]; videoCodecs: string[]; audioCodecs: string[];
} {
const v = document.createElement('video');
const kann = (typ: string): boolean => v.canPlayType(typ) !== '';
const schachteln = ['mp4'];
if (kann('video/webm')) schachteln.push('webm');
const bild: string[] = [];
// H.264 kann jeder Browser, der Video kann — die Prüfung steht trotzdem da,
// weil „jeder" eine Behauptung ist und `canPlayType` eine Auskunft.
if (kann('video/mp4; codecs="avc1.640029"')) bild.push('h264', 'avc1');
if (kann('video/mp4; codecs="hvc1.1.6.L93.B0"')
|| kann('video/mp4; codecs="hev1.1.6.L93.B0"')) bild.push('hevc', 'h265', 'hvc1');
if (kann('video/webm; codecs="vp9"')) bild.push('vp9');
if (kann('video/mp4; codecs="av01.0.05M.08"')) bild.push('av1');
const ton: string[] = [];
if (kann('audio/mp4; codecs="mp4a.40.2"')) ton.push('aac');
if (kann('audio/mpeg')) ton.push('mp3');
if (kann('audio/mp4; codecs="ac-3"')) ton.push('ac3');
if (kann('audio/mp4; codecs="ec-3"')) ton.push('eac3');
if (kann('audio/ogg; codecs="opus"') || kann('audio/webm; codecs="opus"')) ton.push('opus');
if (kann('audio/flac')) ton.push('flac');
// **Eine leere Liste wäre schlimmer als eine ungenaue.** Der Server hielte
// sie für „kein Profil mitgeschickt" und fiele auf Apples zurück — also
// genau auf den Fehler, den das hier beheben soll.
return {
containers: schachteln,
videoCodecs: bild.length > 0 ? bild : ['h264', 'avc1'],
audioCodecs: ton.length > 0 ? ton : ['aac'],
};
}
/* ---------- Der Spieler ---------- */
/** Wie oft der Stand zum Server geht. */
const MELDEABSTAND = 10_000;
/** Die Stufen, die im Menü stehen. Muss zu `Stromentscheid.Stufen` passen. */
const STUFEN: { wert: number | null; text: string }[] = [
{ wert: null, text: 'Original' },
{ wert: 1080, text: '1080p' },
{ wert: 720, text: '720p' },
{ wert: 480, text: '480p' },
];
export type Spieleranfrage = {
dateiId: string;
/** Die Kennung des Titels — dorthin geht der Fortschritt. */
titelId: string;
name: string;
/** Ab welcher Sekunde des Films. */
ab: number;
/** Wie lang der Film ist. **Aus der Mediathek, nicht aus dem Strom** — der
* kennt nur den Rest ab dem Einstiegspunkt und machte aus einem
* Zweistünder ab Minute 90 einen Halbstünder. */
laenge: number | null;
danach: () => void;
};
/** Öffnet den Spieler über der Seite.
*
* **Eigene Steuerung statt `controls`.** Die eingebaute Leiste kennt weder
* Tonspuren noch Qualitätsstufen — und die Tonspurwahl ist der Grund, aus dem
* es diesen Server überhaupt gibt.
*/
export function spieler(anfrage: Spieleranfrage): void {
const schicht = document.createElement('div');
schicht.className = 'pl';
schicht.tabIndex = -1;
const video = document.createElement('video');
video.className = 'pl-video';
video.autoplay = true;
video.playsInline = true;
/** Wo im Film wir stehen — nicht, wo im Strom. */
let vorlauf = anfrage.ab;
let sitzung: string | null = null;
let auskunft: Stromauskunft | null = null;
let hls: HlsTyp | null = null;
let geschlossen = false;
/** Wohin gesprungen wird, sobald die Metadaten da sind. Nur bei Direktwiedergabe. */
let zuSpringen = 0;
/** Die gewählte Stufe und Spuren — sie überleben einen Neustart des Stroms. */
let stufe: number | null = null;
let tonspur: number | null = null;
let untertitel: number | null = null;
const filmzeit = (): number => vorlauf + (Number.isFinite(video.currentTime) ? video.currentTime : 0);
/* ---------- Aufbau ---------- */
const bedienung = document.createElement('div');
bedienung.className = 'pl-leiste';
const kopf = document.createElement('div');
kopf.className = 'pl-kopf';
const name = document.createElement('span');
name.className = 'pl-titel';
name.textContent = anfrage.name;
const zu = document.createElement('button');
zu.className = 'pl-rund';
zu.innerHTML = icons.schliessen;
zu.title = 'Schliessen (Esc)';
zu.setAttribute('aria-label', 'Schliessen');
zu.onclick = () => schliessen();
kopf.append(name, zu);
const mitte = document.createElement('div');
mitte.className = 'pl-mitte';
const meldungsfeld = document.createElement('div');
meldungsfeld.className = 'pl-meldung hidden';
const dreher = document.createElement('div');
dreher.className = 'spinner pl-dreher';
schicht.append(video, dreher, mitte, kopf, bedienung, meldungsfeld);
document.body.appendChild(schicht);
document.body.classList.add('pl-offen');
/* ---------- Steuerung ---------- */
const abspielknopf = rundknopf('abspielen', 'Abspielen (Leertaste)', () => umschalten());
const zeitleiste = document.createElement('input');
zeitleiste.type = 'range';
zeitleiste.className = 'pl-zeit';
zeitleiste.min = '0';
zeitleiste.step = '0.1';
zeitleiste.value = '0';
zeitleiste.setAttribute('aria-label', 'Position im Film');
const stand = document.createElement('span');
stand.className = 'pl-stand';
stand.textContent = '0:00';
const rest = document.createElement('span');
rest.className = 'pl-rest';
const lautstaerke = document.createElement('input');
lautstaerke.type = 'range';
lautstaerke.className = 'pl-laut';
lautstaerke.min = '0';
lautstaerke.max = '1';
lautstaerke.step = '0.05';
lautstaerke.value = '1';
lautstaerke.setAttribute('aria-label', 'Lautstärke');
lautstaerke.oninput = () => {
video.volume = Number(lautstaerke.value);
video.muted = video.volume === 0;
tonknopf.innerHTML = video.muted ? icons.stummGeschaltet : icons.ton;
};
const tonknopf = rundknopf('ton', 'Stumm (m)', () => {
video.muted = !video.muted;
tonknopf.innerHTML = video.muted ? icons.stummGeschaltet : icons.ton;
lautstaerke.value = video.muted ? '0' : String(video.volume);
});
const vollbild = rundknopf('vollbild', 'Vollbild (f)', () => vollbildUmschalten());
/** Die drei Menüs — Ton, Untertitel, Qualität. */
const tonmenue = menue('ton', 'Tonspur');
const textmenue = menue('untertitel', 'Untertitel');
const stufenmenue = menue('zahnrad', 'Qualität');
const links = document.createElement('div');
links.className = 'pl-gruppe';
links.append(abspielknopf, tonknopf, lautstaerke, stand);
const rechts = document.createElement('div');
rechts.className = 'pl-gruppe';
rechts.append(rest, tonmenue.knopf, textmenue.knopf, stufenmenue.knopf, vollbild);
const unten = document.createElement('div');
unten.className = 'pl-unten';
unten.append(links, rechts);
bedienung.append(zeitleiste, unten);
/* ---------- Verhalten ---------- */
video.onloadedmetadata = () => {
// **Erst hier, nicht vorher.** `currentTime` vor den Metadaten zu setzen
// verfällt stillschweigend — genau daran scheiterte „Fortsetzen ab".
//
// Nur bei einer durchgereichten Datei: beim Strom steckt die Stelle schon
// im Schnitt, und noch einmal zu springen hiesse, sie doppelt zu zählen.
if (zuSpringen > 0) {
video.currentTime = zuSpringen;
zuSpringen = 0;
}
dreher.classList.add('hidden');
zeitAuffrischen();
};
video.onplay = () => { abspielknopf.innerHTML = icons.pause; abspielknopf.title = 'Pause (Leertaste)'; };
video.onpause = () => {
abspielknopf.innerHTML = icons.abspielen;
abspielknopf.title = 'Abspielen (Leertaste)';
melden(true);
};
video.onwaiting = () => dreher.classList.remove('hidden');
video.onplaying = () => { dreher.classList.add('hidden'); verbergenPlanen(); };
video.ontimeupdate = () => { zeitAuffrischen(); melden(); };
video.onended = () => { melden(true, true); };
video.onerror = () => zeigeFehler(videofehler());
zeitleiste.oninput = () => { stand.textContent = dauer(Number(zeitleiste.value)); };
zeitleiste.onchange = () => void springen(Number(zeitleiste.value));
/** Die Gesamtlänge des Films — nicht die des Stroms. */
const gesamt = anfrage.laenge ?? 0;
function zeitAuffrischen(): void {
const jetzt = filmzeit();
const laenge = gesamt > 0 ? gesamt : vorlauf + (Number.isFinite(video.duration) ? video.duration : 0);
zeitleiste.max = String(Math.max(1, laenge));
zeitleiste.value = String(jetzt);
stand.textContent = dauer(jetzt);
rest.textContent = laenge > jetzt ? `noch ${dauer(laenge - jetzt)}` : '';
// Der gepufferte Bereich als zweite Farbe in der Leiste.
const anteil = laenge > 0 ? jetzt / laenge : 0;
zeitleiste.style.setProperty('--anteil', `${Math.min(100, anteil * 100)}%`);
}
function umschalten(): void {
if (video.paused) void video.play().catch(() => zeigeFehler(videofehler()));
else video.pause();
}
async function vollbildUmschalten(): Promise<void> {
try {
if (document.fullscreenElement) await document.exitFullscreen();
else await schicht.requestFullscreen();
} catch {
// Lehnt der Browser ab — etwa ohne Benutzergeste —, bleibt es, wie es
// ist. Eine Meldung dafür wäre lauter als der Anlass.
}
}
/* ---------- Melden ---------- */
let zuletzt = 0;
function melden(erzwingen = false, beendet = false): void {
const jetzt = Date.now();
if (!erzwingen && jetzt - zuletzt < MELDEABSTAND) return;
zuletzt = jetzt;
const laenge = gesamt > 0 ? gesamt : null;
// Eine verlorene Meldung kostet ein paar Sekunden Fortschritt. Mitten im
// Film dafür eine Fehlermeldung zu zeigen wäre die schlechtere Hälfte.
void fortschritt(anfrage.titelId, filmzeit(), laenge, beendet).catch(() => {});
}
/* ---------- Strom ---------- */
async function stromAufbauen(ab: number): Promise<void> {
dreher.classList.remove('hidden');
meldungsfeld.classList.add('hidden');
await stromAbbauen();
if (geschlossen) return;
try {
auskunft = await stromStarten(anfrage.dateiId, {
...geraeteprofil(),
height: stufe,
audioStream: tonspur,
startSeconds: ab,
});
} catch (fehler) {
zeigeFehler(meldung(fehler));
return;
}
if (geschlossen) {
if (auskunft.method !== 'direct') void stromBeenden(auskunft.sessionId).catch(() => {});
return;
}
// **Eine Direktwiedergabe hat keine Sitzung.** Der Server schickt dafür
// eine leere Kennung: es gibt nichts zu beenden und nichts aufzuräumen.
const direkt = auskunft.method === 'direct';
sitzung = direkt ? null : auskunft.sessionId;
vorlauf = auskunft.startSeconds;
zuSpringen = direkt ? ab : 0;
if (tonspur === null) tonspur = auskunft.audioStream;
menuesAuffrischen();
untertitelAnhaengen();
if (direkt) {
video.src = auskunft.url;
void video.play().catch(() => dreher.classList.add('hidden'));
return;
}
// **hls.js zuerst, natives HLS nur als Rückfall — und das ist gemessen.**
//
// Erst stand die Prüfung andersherum: wer `application/vnd.apple.mpegurl`
// zu können behauptet, bekommt die Adresse unmittelbar. Chromium
// antwortet darauf aber mit `"maybe"` und kann es dann doch nicht —
// heraus kam ein schwarzes Bild mit „kann die Spuren nicht öffnen".
// `Hls.isSupported()` prüft dagegen, ob es einen MSE-Puffer gibt, und
// das ist eine Tatsache statt einer Vermutung. Safari auf dem iPhone hat
// keinen; dort greift der Rückfall, und dort stimmt die Auskunft auch.
const Hls = await hlsLaden();
if (geschlossen) return;
if (Hls?.isSupported()) {
hls = new Hls({
// **Die Playlist wächst.** Ohne beharrliches Nachfragen sähe hls.js
// nur das erste Segment und hielte den Film für zu Ende.
manifestLoadingMaxRetry: 20,
manifestLoadingRetryDelay: 500,
levelLoadingMaxRetry: 20,
levelLoadingRetryDelay: 500,
});
hls.on(Hls.Events.ERROR, (_: unknown, daten: { fatal: boolean; type: string }) => {
if (!daten.fatal) return;
zeigeFehler(hlsfehler(daten.type, Hls.ErrorTypes));
});
hls.loadSource(auskunft.url);
hls.attachMedia(video);
} else if (video.canPlayType('application/vnd.apple.mpegurl') !== '') {
video.src = auskunft.url;
} else {
zeigeFehler('Dieser Browser kann keine Streams abspielen. '
+ 'Lade den Film herunter oder nimm einen anderen Browser.');
return;
}
void video.play().catch(() => {
// Startet der Browser nicht von selbst — manche verlangen eine Geste —,
// steht der Knopf bereit. Das ist kein Fehler.
dreher.classList.add('hidden');
});
}
async function stromAbbauen(): Promise<void> {
hls?.destroy();
hls = null;
video.removeAttribute('src');
video.load();
if (sitzung) {
const alt = sitzung;
sitzung = null;
await stromBeenden(alt).catch(() => {});
}
}
/** Springt — im Strom, wenn es geht, sonst mit einer neuen Sitzung. */
async function springen(aufFilmzeit: number): Promise<void> {
const imStrom = aufFilmzeit - vorlauf;
const gepuffert = Number.isFinite(video.duration) ? video.duration : 0;
// **Innerhalb des Erzeugten springt der Spieler selbst.** Dafür eine neue
// Sitzung zu starten hiesse, dreissig Sekunden zu warten, um fünf
// Sekunden zurückzugehen.
if (auskunft?.method === 'direct' || (imStrom >= 0 && imStrom <= gepuffert)) {
video.currentTime = Math.max(0, imStrom);
melden(true);
return;
}
melden(true);
await stromAufbauen(aufFilmzeit);
}
/* ---------- Menüs ---------- */
function menuesAuffrischen(): void {
tonmenue.setzen(
(auskunft?.audioStreams ?? []).map(s => ({
text: s.label, gewaehlt: s.index === tonspur,
tun: () => { tonspur = s.index; void stromAufbauen(filmzeit()); },
})));
tonmenue.knopf.classList.toggle('hidden', (auskunft?.audioStreams.length ?? 0) < 2);
textmenue.setzen([
{ text: 'Aus', gewaehlt: untertitel === null, tun: () => untertitelWaehlen(null) },
...(auskunft?.subtitleStreams ?? []).map(s => ({
text: s.label, gewaehlt: s.index === untertitel,
tun: () => untertitelWaehlen(s.index),
})),
]);
textmenue.knopf.classList.toggle('hidden', (auskunft?.subtitleStreams.length ?? 0) === 0);
stufenmenue.setzen(STUFEN.map(s => ({
text: s.wert === null && auskunft?.method === 'transcode' && auskunft.height
? 'Original'
: s.text,
gewaehlt: s.wert === stufe,
tun: () => { stufe = s.wert; void stromAufbauen(filmzeit()); },
})));
}
/** Untertitel umschalten — ohne den Strom anzufassen. */
function untertitelWaehlen(spur: number | null): void {
untertitel = spur;
[...video.textTracks].forEach((t, i) => {
t.mode = spur !== null && video.querySelectorAll('track')[i]?.dataset.spur === String(spur)
? 'showing' : 'disabled';
});
menuesAuffrischen();
}
function untertitelAnhaengen(): void {
video.querySelectorAll('track').forEach(t => t.remove());
(auskunft?.subtitleStreams ?? []).forEach(s => {
const t = document.createElement('track');
t.kind = 'subtitles';
t.label = s.label;
if (s.language) t.srclang = s.language.slice(0, 2);
t.src = untertitelAdresse(anfrage.dateiId, s.index);
t.dataset.spur = String(s.index);
video.appendChild(t);
});
if (untertitel !== null) untertitelWaehlen(untertitel);
}
/* ---------- Fehler ---------- */
function zeigeFehler(text: string): void {
dreher.classList.add('hidden');
meldungsfeld.textContent = text;
meldungsfeld.classList.remove('hidden');
}
/** Was `MediaError` bedeutet — auf Deutsch, ohne Fehlercode.
*
* **Vorher stand hier nichts.** Der Spieler hatte kein `onerror`; scheiterte
* etwas, blieb ein schwarzes Bild stehen, und niemand erfuhr, woran es lag. */
function videofehler(): string {
switch (video.error?.code) {
case MediaError.MEDIA_ERR_ABORTED:
return 'Die Wiedergabe wurde abgebrochen.';
case MediaError.MEDIA_ERR_NETWORK:
return 'Die Verbindung zum Server ist abgerissen. Läuft er noch?';
case MediaError.MEDIA_ERR_DECODE:
return 'Der Browser konnte das Bild nicht dekodieren. '
+ 'Versuche eine kleinere Auflösung über das Zahnrad.';
case MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED:
return 'Dieser Browser kann die Spuren dieser Datei nicht öffnen. '
+ 'Über das Zahnrad lässt sich eine Fassung wählen, die er versteht.';
default:
return 'Die Wiedergabe ist gescheitert. Die Einzelheiten stehen im Protokoll von heute.';
}
}
function hlsfehler(art: string, arten: { NETWORK_ERROR: string; MEDIA_ERROR: string }): string {
if (art === arten.NETWORK_ERROR) {
return 'Der Strom ist abgerissen. Entweder war die Verbindung weg, '
+ 'oder der Server hat die Sitzung aufgeräumt — fang die Wiedergabe neu an.';
}
if (art === arten.MEDIA_ERROR) {
return 'Der Browser kam mit dem Bild nicht zurecht. '
+ 'Versuche eine kleinere Auflösung über das Zahnrad.';
}
return 'Die Wiedergabe ist gescheitert. Die Einzelheiten stehen im Protokoll von heute.';
}
/* ---------- Tastatur ---------- */
function tastendruck(e: KeyboardEvent): void {
if (e.target instanceof HTMLInputElement && e.target.type === 'range' && e.key.startsWith('Arrow')) return;
switch (e.key) {
case ' ': case 'k': e.preventDefault(); umschalten(); break;
case 'Escape': e.preventDefault(); schliessen(); break;
case 'f': e.preventDefault(); void vollbildUmschalten(); break;
case 'm': e.preventDefault(); tonknopf.click(); break;
case 'ArrowLeft': e.preventDefault(); void springen(Math.max(0, filmzeit() - 10)); break;
case 'ArrowRight': e.preventDefault(); void springen(filmzeit() + 10); break;
case 'ArrowUp':
e.preventDefault();
video.volume = Math.min(1, video.volume + 0.1);
lautstaerke.value = String(video.volume);
break;
case 'ArrowDown':
e.preventDefault();
video.volume = Math.max(0, video.volume - 0.1);
lautstaerke.value = String(video.volume);
break;
default: return;
}
zeigen();
}
schicht.addEventListener('keydown', tastendruck);
// **Wer zurückgeht, will nicht weiter zusehen.** Der Spieler hängt an
// `document.body` und nicht am Inhaltsbereich; ohne diese Zeile blieb er
// über der neuen Seite stehen, und ffmpeg lief dazu weiter.
const beiNavigation = (): void => schliessen();
window.addEventListener('hashchange', beiNavigation);
window.addEventListener('popstate', beiNavigation);
// **Und beim Schliessen des Reiters.** `DELETE` als gewöhnliche Anfrage
// kommt dabei oft nicht mehr durch; `sendBeacon` ist genau dafür da.
const beiEntladen = (): void => {
if (sitzung) navigator.sendBeacon?.(`/api/v1/stream/${sitzung}/schliessen`);
};
window.addEventListener('pagehide', beiEntladen);
/* ---------- Leiste ein- und ausblenden ---------- */
let verstecker: number | undefined;
function zeigen(): void {
schicht.classList.remove('pl-still');
verbergenPlanen();
}
function verbergenPlanen(): void {
window.clearTimeout(verstecker);
if (video.paused) return;
verstecker = window.setTimeout(() => {
// **Nicht, solange ein Menü offen steht.** Sonst verschwindet die Liste
// unter der Hand, während man die Tonspur liest — gesehen beim Prüfen,
// und es war genau so ärgerlich, wie es klingt.
if (bedienung.querySelector('.pl-menue.auf')) { verbergenPlanen(); return; }
schicht.classList.add('pl-still');
}, 3000);
}
schicht.onmousemove = zeigen;
schicht.onclick = e => {
// Ein Klick neben ein offenes Menü schliesst es, statt den Film anzuhalten.
const offen = bedienung.querySelector('.pl-menue.auf');
if (offen && !offen.contains(e.target as Node)) { offen.classList.remove('auf'); return; }
if (e.target === schicht || e.target === video) umschalten();
};
/* ---------- Schliessen ---------- */
function schliessen(): void {
if (geschlossen) return;
geschlossen = true;
video.pause();
// **Beendet melden, nicht nur den Stand.** Sonst steht der Film im
// Dashboard noch eine Minute als „läuft".
melden(true, true);
void stromAbbauen();
window.clearTimeout(verstecker);
window.removeEventListener('hashchange', beiNavigation);
window.removeEventListener('popstate', beiNavigation);
window.removeEventListener('pagehide', beiEntladen);
if (document.fullscreenElement) void document.exitFullscreen().catch(() => {});
document.body.classList.remove('pl-offen');
schicht.remove();
anfrage.danach();
}
/* ---------- Hilfsbausteine ---------- */
function rundknopf(bild: keyof typeof icons, titel: string, tun: () => void): HTMLButtonElement {
const b = document.createElement('button');
b.className = 'pl-rund';
b.innerHTML = icons[bild];
b.title = titel;
b.setAttribute('aria-label', titel);
b.onclick = e => { e.stopPropagation(); tun(); };
return b;
}
type Wahl = { text: string; gewaehlt: boolean; tun: () => void };
/** Ein kleines Menü, das über der Leiste aufgeht. */
function menue(bild: keyof typeof icons, titel: string): {
knopf: HTMLElement; setzen: (punkte: Wahl[]) => void;
} {
const huelle = document.createElement('div');
huelle.className = 'pl-menue';
const ausloeser = rundknopf(bild, titel, () => {
const offen = huelle.classList.toggle('auf');
// Nur eines auf einmal — zwei offene Listen übereinander sind ein Rätsel.
if (offen) {
bedienung.querySelectorAll('.pl-menue.auf').forEach(m => {
if (m !== huelle) m.classList.remove('auf');
});
}
// **Und die Leiste bleibt stehen.** War sie gerade weggeblendet, ginge
// das Menü sonst hinter einer unsichtbaren Leiste auf.
zeigen();
});
const liste = document.createElement('div');
liste.className = 'pl-liste';
huelle.append(ausloeser, liste);
return {
knopf: huelle,
setzen: punkte => {
liste.innerHTML = '';
punkte.forEach(p => {
const b = knopf(p.text, 'pl-punkt' + (p.gewaehlt ? ' an' : ''));
if (p.gewaehlt) b.prepend(icon('haken'));
b.onclick = e => {
e.stopPropagation();
huelle.classList.remove('auf');
p.tun();
};
liste.appendChild(b);
});
},
};
}
/* ---------- Los ---------- */
menuesAuffrischen();
zeitAuffrischen();
schicht.focus();
void stromAufbauen(anfrage.ab);
}
/** Holt hls.js erst, wenn wirklich etwas abgespielt wird.
*
* **Fest eingebunden wog die Oberfläche 685 KB statt 97.** Das sind sechs
* Siebtel für etwas, das beim Durchblättern der Mediathek niemand braucht —
* und gemessen, nicht geschätzt: der Bau sagt es.
*
* **Die schlanke Fassung reicht.** Was die vollständige mehr kann — getrennte
* Tonspuren im Strom, Untertitel in der Playlist, Kopierschutz — brauchen wir
* nicht: die Tonspur steckt im Strom, die Untertitel kommen als eigene Datei.
*/
let hlsVersprechen: Promise<typeof HlsTyp | null> | null = null;
function hlsLaden(): Promise<typeof HlsTyp | null> {
hlsVersprechen ??= import('hls.js/light')
.then(m => m.default)
.catch(() => {
// Kommt die Datei nicht an — kein Netz, ein Proxy dazwischen —, ist das
// kein Grund abzustürzen. Der Aufrufer zeigt dann einen Satz.
hlsVersprechen = null;
return null;
});
return hlsVersprechen;
}
export type { Spurauskunft };
+134 -3
View File
@@ -718,9 +718,140 @@ h2 { font-size: var(--t-abschnitt); font-weight: 700; letter-spacing: -.015em; m
.btn-gefahr { background: var(--destructive); color: #fff; font-weight: 600; }
.btn-gefahr:hover { background: var(--destructive-dim); }
/* Der Spieler über allem. */
.pl-fill {
position: fixed; inset: 0; z-index: 100; background: #000;
/* ---------- Der Spieler ----------
*
* **Eigene Steuerung statt der eingebauten.** Die Leiste des Browsers kennt
* weder Tonspuren noch Qualitätsstufen — und die Tonspurwahl ist der Grund,
* aus dem es diesen Server gibt. Ausserdem sah sie, wie der Benutzer sagte,
* „gefühlt von 1930" aus. */
.pl {
position: fixed; inset: 0; z-index: 150; background: #000;
display: grid; place-items: center; outline: none;
}
.pl-video { width: 100%; height: 100%; object-fit: contain; background: #000; }
.pl-dreher { position: absolute; margin: 0; }
/* Kopf und Leiste blenden sich weg, während etwas läuft. */
.pl-kopf, .pl-leiste {
position: absolute; left: 0; right: 0; z-index: 2;
display: flex; align-items: center; gap: var(--s-regular);
padding: 14px 18px;
transition: opacity .2s ease-out, transform .2s ease-out;
}
.pl-kopf {
top: 0; justify-content: space-between;
background: linear-gradient(to bottom, rgba(0, 0, 0, .78), transparent);
padding-top: max(14px, env(safe-area-inset-top));
}
.pl-leiste {
bottom: 0; flex-direction: column; align-items: stretch; gap: 8px;
background: linear-gradient(to top, rgba(0, 0, 0, .86), transparent);
padding-bottom: max(14px, env(safe-area-inset-bottom));
}
.pl-still .pl-kopf { opacity: 0; transform: translateY(-8px); pointer-events: none; }
.pl-still .pl-leiste { opacity: 0; transform: translateY(8px); pointer-events: none; }
.pl-still .pl-video { cursor: none; }
.pl-titel {
font-size: 15px; font-weight: 600; color: #fff;
text-shadow: 0 1px 3px rgba(0, 0, 0, .7);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.pl-unten { display: flex; align-items: center; justify-content: space-between; gap: var(--s-regular); }
.pl-gruppe { display: flex; align-items: center; gap: 6px; min-width: 0; }
/* **44 Bildpunkte, nicht 32.** Darunter trifft ein Finger daneben, und ein
Fehlgriff auf der Zeitleiste wirft einen aus dem Film. */
.pl-rund {
display: grid; place-items: center;
width: 44px; height: 44px; flex: 0 0 auto;
border: 0; border-radius: 50%; background: transparent; color: #fff;
cursor: pointer; transition: background .15s;
}
.pl-rund:hover, .pl-rund:focus-visible { background: rgba(255, 255, 255, .16); }
.pl-rund svg { width: 22px; height: 22px; }
.pl-stand, .pl-rest {
font-size: 13px; color: rgba(255, 255, 255, .88);
font-variant-numeric: tabular-nums; white-space: nowrap;
}
/* Zeit- und Lautstärkeregler: dieselbe Form, verschiedene Breite. */
.pl-zeit, .pl-laut {
-webkit-appearance: none; appearance: none;
height: 20px; background: transparent; cursor: pointer; margin: 0;
}
.pl-zeit { width: 100%; }
.pl-laut { width: 84px; flex: 0 0 auto; }
.pl-zeit::-webkit-slider-runnable-track, .pl-laut::-webkit-slider-runnable-track {
height: 4px; border-radius: 2px;
background: linear-gradient(to right, var(--accent) var(--anteil, 0%),
rgba(255, 255, 255, .28) var(--anteil, 0%));
}
.pl-laut::-webkit-slider-runnable-track { background: rgba(255, 255, 255, .28); }
.pl-zeit::-moz-range-track, .pl-laut::-moz-range-track {
height: 4px; border-radius: 2px; background: rgba(255, 255, 255, .28);
}
.pl-zeit::-moz-range-progress { height: 4px; border-radius: 2px; background: var(--accent); }
.pl-zeit::-webkit-slider-thumb, .pl-laut::-webkit-slider-thumb {
-webkit-appearance: none; appearance: none;
width: 14px; height: 14px; margin-top: -5px;
border-radius: 50%; background: #fff; border: 0;
box-shadow: 0 1px 4px rgba(0, 0, 0, .5);
}
.pl-zeit::-moz-range-thumb, .pl-laut::-moz-range-thumb {
width: 14px; height: 14px; border-radius: 50%; background: #fff; border: 0;
}
.pl-zeit:focus-visible, .pl-laut:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
/* Die Menüs gehen nach oben auf — nach unten wäre ausserhalb des Bildes. */
.pl-menue { position: relative; }
.pl-liste {
position: absolute; bottom: calc(100% + 8px); right: 0;
display: none; flex-direction: column; min-width: 180px;
padding: 6px; background: rgba(20, 20, 23, .96);
border: 1px solid var(--line); border-radius: var(--r-karte);
box-shadow: 0 12px 36px rgba(0, 0, 0, .6);
max-height: 50vh; overflow-y: auto;
}
.pl-menue.auf .pl-liste { display: flex; }
.pl-punkt {
display: flex; align-items: center; gap: 8px;
background: none; border: 0; color: var(--text);
padding: 9px 12px; border-radius: var(--r-control);
font-size: 14px; text-align: left; cursor: pointer; white-space: nowrap;
}
.pl-punkt:hover, .pl-punkt:focus-visible { background: var(--raised); }
.pl-punkt .icon { width: 16px; height: 16px; flex: 0 0 auto; }
.pl-punkt .icon svg { width: 16px; height: 16px; }
/* Ohne Haken bleibt der Platz stehen — sonst springt die Beschriftung. */
.pl-punkt:not(.an) { padding-left: 36px; }
.pl-meldung {
position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
z-index: 3; max-width: min(520px, calc(100vw - 48px));
padding: var(--s-regular) var(--s-section);
background: rgba(20, 20, 23, .96); color: var(--text);
border: 1px solid rgba(229, 72, 77, .45); border-radius: var(--r-karte);
text-align: center; line-height: 1.5; font-size: 15px;
}
/* Hinter dem Spieler soll nichts scrollen. */
body.pl-offen { overflow: hidden; }
@media (max-width: 640px) {
.pl-laut { display: none; }
.pl-rest { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.pl-kopf, .pl-leiste { transition: none; }
}
/* ---------- Schmale Bildschirme ---------- */
+28 -62
View File
@@ -1,6 +1,6 @@
import { ApiFehler,
film as filmLaden, serie as serieLaden, folgen, lieferung, downloadAdresse,
vorschlaege, zuordnen, bildAdresse, istAdmin, fortschritt, gesehen, favorit, weglegen,
vorschlaege, zuordnen, bildAdresse, istAdmin, gesehen, favorit, weglegen,
type Film, type Serie, type Folge, type Datei, type Lieferung, type Stand,
type Mitwirkender, aehnliche,
} from '../api';
@@ -10,6 +10,7 @@ import {
import { icons, icon } from '../icons';
import { kachel, alsKachel, reihe } from '../card';
import { vergessen } from '../ansichten';
import { spieler } from '../spieler';
type Art = 'filme' | 'serien';
@@ -227,6 +228,11 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
/* ---------- Knöpfe ---------- */
/** Wie der Titel über dem Spieler steht. */
function titelzeile(eintrag: Film | Serie | { title?: string }): string {
return 'title' in eintrag && eintrag.title ? eintrag.title : 'Wiedergabe';
}
function aktionsreihe(eintrag: Film | Serie, stand: Stand | null, datei: Datei | null): HTMLElement {
const reihe = document.createElement('div');
reihe.className = 'aktionen';
@@ -237,12 +243,12 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
const stelle = stand?.positionSeconds ?? 0;
const los = knopf(stelle > 0 ? `Fortsetzen ab ${dauer(stelle)}` : 'Abspielen', 'btn btn-primary');
los.prepend(icon('abspielen'));
los.onclick = () => void starten(datei, eintrag.id, stelle, los, hinweisfeld);
los.onclick = () => starten(datei, eintrag.id, titelzeile(eintrag), stelle);
reihe.appendChild(los);
if (stelle > 0) {
const vonVorn = knopf('Von vorn', 'btn');
vonVorn.onclick = () => void starten(datei, eintrag.id, 0, vonVorn, hinweisfeld);
vonVorn.onclick = () => starten(datei, eintrag.id, titelzeile(eintrag), 0);
reihe.appendChild(vonVorn);
}
@@ -527,6 +533,11 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
/* ---------- Abspielen und Herunterladen ---------- */
/** Sorgt dafür, dass die Datei abholbereit ist — packt um, falls nötig. */
/** Baut die Datei für den **Download** auf — dort ist eine fertige richtig.
*
* **Nicht mehr fürs Abspielen.** Dafür gibt es den Strom, der sofort
* anfängt; hier auf eine vollständige MP4 zu warten hiess bei einem
* 4K-Film: Minuten, in denen sichtbar nichts geschieht. */
async function bereitstellen(datei: Datei, hinweisfeld: HTMLElement): Promise<boolean> {
let auskunft: Lieferung;
try { auskunft = await lieferung(datei.id); }
@@ -555,12 +566,20 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
return false;
}
async function starten(
datei: Datei, titel: string, ab: number, knopfDavon: HTMLButtonElement, hinweisfeld: HTMLElement,
): Promise<void> {
knopfDavon.disabled = true;
if (await bereitstellen(datei, hinweisfeld)) spieler(downloadAdresse(datei.id), titel, ab, laden);
knopfDavon.disabled = false;
/** Startet die Wiedergabe — ohne Vorlauf, ohne Warten.
*
* **Der Spieler baut den Strom selbst auf**, weil er ihn auch wieder
* abbauen und bei jedem Wechsel der Auflösung neu anlegen muss. Ihn hier
* fertig anzureichen hiesse, dieselbe Arbeit an zwei Stellen zu haben. */
function starten(datei: Datei, titelId: string, name: string, ab: number): void {
spieler({
dateiId: datei.id,
titelId,
name,
ab,
laenge: datei.durationSeconds,
danach: laden,
});
}
async function herunterladen(
@@ -650,56 +669,3 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
return wurzel;
}
/* ---------- Der Spieler ---------- */
/** Wie oft der Stand zum Server geht. */
const MELDEABSTAND = 10_000;
function spieler(adresse: string, titel: string, ab: number, danach: () => void): void {
const schicht = document.createElement('div');
schicht.className = 'pl-fill';
const video = document.createElement('video');
video.src = adresse;
video.controls = true;
video.autoplay = true;
video.style.cssText = 'width:100%;height:100%;background:#000';
if (ab > 0) video.currentTime = ab;
// **Alle zehn Sekunden, nicht bei jedem Bild.** `timeupdate` feuert
// viermal je Sekunde; daraus Anfragen zu machen hiesse, den Server mit
// Meldungen zu bewerfen, die sich um 250 ms unterscheiden.
let zuletzt = 0;
const melden = (erzwingen = false, beendet = false) => {
const jetzt = Date.now();
if (!erzwingen && jetzt - zuletzt < MELDEABSTAND) return;
zuletzt = jetzt;
const laenge = Number.isFinite(video.duration) ? video.duration : null;
// Eine verlorene Meldung kostet ein paar Sekunden Fortschritt. Dafür
// mitten im Film eine Fehlermeldung zu zeigen wäre die schlechtere
// Hälfte des Tauschs.
void fortschritt(titel, video.currentTime, laenge, beendet).catch(() => {});
};
video.ontimeupdate = () => melden();
video.onpause = () => melden(true);
video.onended = () => melden(true, true);
const schliessen = knopf('Schliessen', 'btn');
schliessen.style.cssText = 'position:absolute;top:16px;right:16px;z-index:2';
schliessen.onclick = () => {
video.pause();
// **Beendet melden, nicht nur den Stand.** Sonst steht der Film im
// Dashboard noch eine Minute als „läuft", und wer gerade geschlossen
// hat, sieht sich selbst beim Schauen zu.
melden(true, true);
schicht.remove();
danach();
};
schicht.append(video, schliessen);
schicht.onkeydown = e => { if (e.key === 'Escape') schliessen.click(); };
document.body.appendChild(schicht);
video.focus();
}