diff --git a/server/src/Flaro.Api/Endpoints/Contracts.cs b/server/src/Flaro.Api/Endpoints/Contracts.cs index 80a8cf5..1e604be 100644 --- a/server/src/Flaro.Api/Endpoints/Contracts.cs +++ b/server/src/Flaro.Api/Endpoints/Contracts.cs @@ -430,6 +430,47 @@ public sealed record BulkWatchedResponse(int Changed, IReadOnlyList Unknow /// Welche Titel neu geholt werden sollen — leer heisst: alle. public sealed record RefreshRequest(IReadOnlyList? Ids); + +/// Was ein Spieler kann und was er sehen will. +/// +/// Schachteln, die das Gerät öffnet — „mp4", „webm". +/// +/// Die gewünschte Stufe: 1080, 720 oder 480. null heisst Originalauflösung. +/// +/// +/// Welche Tonspur — die Nummer aus der Datei. null nimmt die erste. +/// +/// Ab welcher Sekunde des Films. +/// +/// +/// Das Gerät sagt, was es kann, statt dass der Server es rät. Vorher +/// bekam jeder Browser Apples Profil — und mit ihm HEVC, AC3 und EAC3, von +/// denen er keines öffnen kann. +/// +public sealed record StreamRequest( + IReadOnlyList? Containers, + IReadOnlyList? VideoCodecs, + IReadOnlyList? AudioCodecs, + int? Height = null, + int? AudioStream = null, + double? StartSeconds = null); + +/// Eine wählbare Spur, so wie sie im Menü steht. +public sealed record SpurAuskunft(int Index, string Label, string? Language, bool IsDefault); + +/// +/// Ab welcher Sekunde des Films dieser Strom beginnt. Der Spieler zählt ab +/// null — ohne diesen Wert ginge jeder gemeldete Stand um den Vorlauf daneben. +/// +public sealed record StreamResponse( + Guid SessionId, + string Url, + string Method, + double StartSeconds, + int? Height, + int? AudioStream, + IReadOnlyList AudioStreams, + IReadOnlyList SubtitleStreams); public sealed record RatingRequest(int? Rating); /// Was ein Benutzer mit einem Titel zu tun hatte. diff --git a/server/src/Flaro.Api/Endpoints/StreamEndpoints.cs b/server/src/Flaro.Api/Endpoints/StreamEndpoints.cs new file mode 100644 index 0000000..286e7c0 --- /dev/null +++ b/server/src/Flaro.Api/Endpoints/StreamEndpoints.cs @@ -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; + +/// Wiedergabe, die sofort anfängt — statt auf eine fertige Datei zu warten. +/// +/// +/// +/// Warum es diesen zweiten Weg gibt. +/// 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. +/// +/// +/// Der Browser sagt, was er kann. 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. +/// +/// +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(); + } + + /// Die Datei, wenn der Browser sie unverändert öffnen kann — sonst null. + private static async Task 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 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()); + } + + /// Das Geräteprofil aus der Anfrage — oder, wenn keines kam, Apples. + /// + /// + /// Die Rückfallebene ist nicht harmlos. 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. + /// + 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(anfrage.Containers, StringComparer.OrdinalIgnoreCase), + new HashSet(anfrage.VideoCodecs, StringComparer.OrdinalIgnoreCase), + new HashSet(anfrage.AudioCodecs, StringComparer.OrdinalIgnoreCase)); + } + + private static async Task 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()); + } + + /// Bildbasierte Untertitel lassen sich nicht nach WebVTT wandeln. + /// + /// + /// Sie anzubieten wäre eine Zusage, die nicht eingelöst werden kann. + /// PGS ist auf jedem Blu-ray-Rip die Regel — ein Eintrag im Menü, der zu + /// nichts führt, ist schlechter als kein Eintrag. + /// + private static bool IstBild(string codec) => + codec is "hdmv_pgs_subtitle" or "pgssub" or "dvd_subtitle" or "dvdsub" or "xsub"; + + /// Wie eine Spur im Menü heisst. + private static string Benennung(MediaStream spur) + { + if (spur.Title is { Length: > 0 } eigen) return eigen; + + var sprache = Sprache(spur.Language); + var teile = new List { sprache }; + if (spur.Channels is { } k and > 2) teile.Add($"{k} Kanäle"); + if (spur.IsForced) teile.Add("erzwungen"); + return string.Join(" · ", teile); + } + + /// Dreibuchstabige Kennungen in Worte — für die üblichen. + /// + /// + /// Keine vollständige Tabelle. „deu" steht auf fast jeder Datei + /// hier; die übrigen bleiben, wie sie sind, statt eine Liste zu pflegen, + /// die nie fertig wird. + /// + 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(), + }; +} diff --git a/server/src/Flaro.Api/Program.cs b/server/src/Flaro.Api/Program.cs index 5b424dc..cfd71fa 100644 --- a/server/src/Flaro.Api/Program.cs +++ b/server/src/Flaro.Api/Program.cs @@ -104,6 +104,11 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + +// **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(); +builder.Services.AddSingleton(); // 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(); @@ -259,6 +264,7 @@ app.MapLibraryContent(); app.MapSettings(); app.MapImages(); app.MapDelivery(); +app.MapStream(); app.MapPlayback(); app.MapMatching(); app.MapArtwork(); diff --git a/server/src/Flaro.Api/Streamkoordinator.cs b/server/src/Flaro.Api/Streamkoordinator.cs new file mode 100644 index 0000000..cacba0a --- /dev/null +++ b/server/src/Flaro.Api/Streamkoordinator.cs @@ -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; + +/// Eine laufende Wiedergabe mit ihrem ffmpeg dahinter. +/// +/// +/// Ab welcher Sekunde des Films diese Sitzung beginnt. Die Zeitachse des +/// Spielers fängt bei null an, 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. +/// +public sealed record Stromsitzung( + Guid Kennung, + Guid DateiId, + Guid Benutzer, + string Ordner, + double Ab, + int? Hoehe, + int? Tonspur, + Stromart Art, + DateTimeOffset Begonnen) +{ + /// Wann zuletzt ein Segment abgeholt wurde. + /// + /// + /// Daran hängt das Aufräumen. Ein geschlossener Browser sagt + /// niemandem Bescheid; bliebe die Sitzung stehen, liefe ffmpeg weiter und + /// schriebe den ganzen Film auf den Kratzplatz. + /// + public DateTimeOffset Gesehen { get; set; } = Begonnen; + + public string? Fehler { get; set; } + public bool Beendet { get; set; } +} + +/// Verwaltet laufende Ströme: starten, bedienen, aufräumen. +/// +/// +/// +/// Neben , nicht an seiner Stelle. 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. +/// +/// +/// Eine Sitzung je Wiedergabe, nicht je Datei. 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. +/// +/// +public sealed class Streamkoordinator( + IServiceScopeFactory scopes, + FlaroPaths pfade, + FfmpegOptions ffmpeg, + TimeProvider clock, + ILogger log) : IDisposable +{ + private readonly ConcurrentDictionary _sitzungen = new(); + + private sealed record Eintrag(Stromsitzung Sitzung, Process Prozess, CancellationTokenSource Abbruch); + + /// Nach dieser Stille gilt eine Sitzung als verlassen. + /// + /// + /// Grosszügiger als ein Segment lang. 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. + /// + public static readonly TimeSpan Geduld = TimeSpan.FromMinutes(2); + + /// Wie viele Ströme gleichzeitig laufen dürfen. + /// + /// + /// Weil Umkodieren einen ganzen Rechner beschäftigt. Vier + /// gleichzeitige 4K-Läufe bringen jedes NAS zum Stehen — und dann + /// ruckelt nicht einer, sondern alle vier. + /// + private const int HoechstensGleichzeitig = 6; + + public int LaufendeAnzahl => _sitzungen.Count; + + public Stromsitzung? Sitzung(Guid kennung) => _sitzungen.GetValueOrDefault(kennung)?.Sitzung; + + /// Startet einen Strom und gibt die Sitzung zurück. + public async Task 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(); + + 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; + } + + /// Hält eine Sitzung am Leben — bei jedem abgeholten Segment. + public void Lebenszeichen(Guid kennung) + { + if (_sitzungen.TryGetValue(kennung, out var eintrag)) + eintrag.Sitzung.Gesehen = clock.GetUtcNow(); + } + + /// Beendet eine Sitzung und räumt ihren Ordner weg. + public void Beenden(Guid kennung) + { + if (!_sitzungen.TryRemove(kennung, out var eintrag)) return; + Abraeumen(eintrag); + } + + /// Beendet, was niemand mehr abholt. + /// + /// + /// Beim Starten einer neuen Sitzung, nicht in einem eigenen Takt. + /// Ein Hintergrunddienst für einen Handgriff, der ohnehin selten + /// geschieht, wäre ein Faden mehr, der schiefgehen kann. + /// + 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); + } + } + + /// Ob die Playlist schon etwas enthält, das sich abspielen lässt. + /// + /// + /// Die Datei allein genügt nicht. ffmpeg legt sie an, bevor das + /// erste Segment fertig ist; ein Spieler, der sie dann bekommt, sieht + /// eine leere Liste und gibt auf. + /// + 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; } + } + + /// Welche Beschleunigung benutzt wird. + /// + /// + /// auto prüft, statt zu behaupten. Liegt kein + /// /dev/dri/renderD128 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. + /// + 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); + } + } + + /// Beim Herunterfahren bleibt nichts laufen und nichts liegen. + 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) { } + } +} diff --git a/server/src/Flaro.Core/Delivery/Streamplan.cs b/server/src/Flaro.Core/Delivery/Streamplan.cs new file mode 100644 index 0000000..7077f14 --- /dev/null +++ b/server/src/Flaro.Core/Delivery/Streamplan.cs @@ -0,0 +1,133 @@ +using Flaro.Core.Library; + +namespace Flaro.Core.Delivery; + +/// Wie eine Datei in einen laufenden Spieler kommt. +/// +/// +/// Nicht zu verwechseln mit . 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. +/// +public enum Stromart +{ + /// Die Datei geht unverändert hinaus, der Browser spielt sie direkt. + Direkt, + + /// + /// Umgepackt, aber nicht neu gerechnet — mkv nach fMP4, Bild Byte für Byte. + /// + /// + /// + /// Das ist der häufigste Fall und der billigste. 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. + /// + Umpacken, + + /// Das Bild wird neu gerechnet — anderer Codec oder kleinere Auflösung. + Umkodieren, +} + +/// Warum es nicht beim Durchreichen bleibt. +public enum Stromgrund +{ + PasstSchon, + SchachtelUnbekannt, + BildcodecUnbekannt, + TonUnbekannt, + KleinereAufloesungGewuenscht, +} + +/// Was für diesen Strom zu tun ist. +/// +/// +/// Auf wie viele Bildzeilen herunterskaliert wird — null heisst: +/// Originalauflösung. +/// +public sealed record Streamplan( + Stromart Art, + IReadOnlyList Gruende, + int? Hoehe) +{ + public bool BildKopieren => Art != Stromart.Umkodieren; +} + +/// Entscheidet, was ein Strom braucht, damit der Spieler ihn öffnet. +/// +/// +/// +/// Getrennt von , und das ist Absicht. 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. +/// +/// +/// Das Bild wird nur angefasst, wenn es sein muss. 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. +/// +/// +public static class Stromentscheid +{ + /// Die Stufen, die ein Spieler anbieten darf. + /// + /// + /// Keine freie Zahl. 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. + /// + public static readonly IReadOnlyList Stufen = [1080, 720, 480]; + + /// + /// Die vom Spieler gewählte Stufe, oder null für das Original. + /// + public static Streamplan Fuer( + string? schachtel, + IReadOnlyList spuren, + DeviceProfile profil, + int? hoeheGewuenscht = null) + { + var gruende = new List(); + + 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); + } + + /// Was fehlt, zählt als passend — eine Lücke ist kein Urteil. + private static bool Passt(string? wert, IReadOnlySet erlaubt) => + string.IsNullOrWhiteSpace(wert) || erlaubt.Contains(wert); +} diff --git a/server/src/Flaro.Media/HlsArgumente.cs b/server/src/Flaro.Media/HlsArgumente.cs new file mode 100644 index 0000000..eb22f87 --- /dev/null +++ b/server/src/Flaro.Media/HlsArgumente.cs @@ -0,0 +1,243 @@ +using System.Globalization; +using Flaro.Core.Delivery; +using Flaro.Core.Library; + +namespace Flaro.Media; + +/// Welche Bildbeschleunigung benutzt werden soll. +public enum Beschleunigung +{ + /// Alles im Hauptprozessor — geht überall, kostet am meisten. + Software, + + /// VAAPI über /dev/dri — Intel und AMD unter Linux. + Vaapi, +} + +/// Die Datei auf der Platte. +/// Wohin Playlist und Segmente geschrieben werden. +/// +/// Welche Tonspur gespielt wird — die Nummer aus der Datei. null nimmt +/// die erste. +/// +/// +/// Ab welcher Sekunde. Vor -i, nicht danach — nur dann springt +/// ffmpeg im Vorspann statt die ganze Datei durchzulesen. +/// +public sealed record Stromanfrage( + string Quelle, + string Zielordner, + IReadOnlyList Spuren, + Streamplan Plan, + int? Tonspur = null, + double Ab = 0, + Beschleunigung Hardware = Beschleunigung.Software); + +/// Baut die ffmpeg-Zeile für einen laufenden Strom. +/// +/// +/// +/// Eine reine Funktion, wie bei . Aus +/// demselben Grund: eine falsche Parameterkombination lässt sich sonst nur +/// gegen einen echten Server entdecken, und dort kostet sie Tage. +/// +/// +/// Ein Rohr für beides. Eine MKV nach fMP4 umzupacken und einen +/// 4K-Film auf 480p herunterzurechnen ist derselbe Weg — einmal mit +/// -c:v copy, einmal mit einem Kodierer. Zwei getrennte Wege hiesse, +/// Sitzungsverwaltung, Segmentierung und Aufräumen zweimal zu haben. +/// +/// +/// Warum HLS und nicht eine fertige Datei. 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. +/// +/// +public static class HlsArgumente +{ + /// Wie lang ein Segment ist. + /// + /// + /// Sechs Sekunden, wie bei Apple. Kürzer heisst mehr Dateien und + /// mehr Anfragen; länger heisst, dass der Anfang später kommt und jeder + /// Sprung gröber wird. + /// + public const int SegmentSekunden = 6; + + /// Wie viele Bilder zwischen zwei Schlüsselbildern liegen. + /// + /// + /// Ein Segment muss mit einem Schlüsselbild anfangen. Ohne feste + /// Abstände schneidet ffmpeg dort, wo zufällig eines liegt — und die + /// Segmente werden unterschiedlich lang, was jeden Sprung ungenau macht. + /// + 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 Fuer(Stromanfrage anfrage) + { + var argumente = new List { "-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; + } + + /// Welche Tonspur gespielt wird. + /// + /// + /// + /// Eine gewünschte, die es nicht gibt, ist keine Fehlermeldung wert. + /// Nach einem erneuten Scan kann sich die Nummerierung verschoben haben; + /// dann ist die erste Spur die vernünftige Antwort, nicht ein stummer Film. + /// + /// + /// Öffentlich, damit die Antwort an den Spieler dieselbe Regel + /// benutzt. Vorher meldete der Server die gewünschte Spur + /// zurück — also meist null —, und im Menü stand bei keiner ein + /// Haken, obwohl eine davon lief. + /// + /// + public static ProbedStream? Tonspur(IReadOnlyList 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 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(); + 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 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"]; + } +} diff --git a/server/src/Flaro.Media/UntertitelLeser.cs b/server/src/Flaro.Media/UntertitelLeser.cs new file mode 100644 index 0000000..e94df2a --- /dev/null +++ b/server/src/Flaro.Media/UntertitelLeser.cs @@ -0,0 +1,99 @@ +using System.Diagnostics; +using Microsoft.Extensions.Logging; + +namespace Flaro.Media; + +/// Holt eine Untertitelspur als WebVTT aus einer Datei. +/// +/// +/// +/// Getrennt vom Strom, und das ist der Punkt. 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 <track> ein und schaltet in +/// Millisekunden um. +/// +/// +/// Nur Text, kein Bild. PGS und VobSub sind Bilder; aus ihnen WebVTT +/// zu machen hiesse, Schrift zu erkennen. Sie werden gar nicht erst +/// angeboten — siehe StreamEndpoints. +/// +/// +public sealed class UntertitelLeser(FfmpegOptions optionen, ILogger log) +{ + /// Wie lange das Herausziehen höchstens dauern darf. + /// + /// + /// 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. + /// + private static readonly TimeSpan Geduld = TimeSpan.FromSeconds(30); + + /// null, wenn es die Spur nicht gibt oder sie kein Text ist. + public async Task 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; + } + } +} diff --git a/server/tests/Flaro.Api.Tests/StreamTests.cs b/server/tests/Flaro.Api.Tests/StreamTests.cs new file mode 100644 index 0000000..fc4a639 --- /dev/null +++ b/server/tests/Flaro.Api.Tests/StreamTests.cs @@ -0,0 +1,330 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Http.Json; +using Flaro.Api.Endpoints; + +namespace Flaro.Api.Tests; + +/// Wiedergabe, die sofort anfängt — von der Anfrage bis zum Segment. +/// +/// +/// Mit echtem ffmpeg, wie bei den Lieferungsprüfungen. Ob eine Playlist +/// entsteht und ob darin Segmente stehen, lässt sich nicht behaupten; es muss +/// jemand nachsehen. +/// +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) { } + } + + /// Was ein gewöhnlicher Browser kann — kein HEVC, kein EAC3. + 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); + + /// Eine MKV mit zwei Tonspuren, die zweite in einem Codec für Apple. + 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())!; + + (await client.PostAsync($"/api/v1/libraries/{bibliothek.Id}/scan", null)).EnsureSuccessStatusCode(); + for (var i = 0; i < 200; i++) + { + var stand = await client.GetFromJsonAsync($"/api/v1/libraries/{bibliothek.Id}/scan"); + if (stand!.State == "finished") break; + await Task.Delay(50); + } + + var filme = await client.GetFromJsonAsync>("/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 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())!; + } + + // ------------------------------------------------------------------ + + /// Eine MKV wird für den Browser umgepackt, nicht neu gerechnet. + [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)); + }); + } + + /// Die Playlist kommt, sobald das erste Segment da ist. + /// + /// + /// Und nicht als 204. 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. + /// + [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)); + }); + } + + /// Ein unbekannter Bildcodec führt zum Rechnen, nicht zum Umpacken. + /// + /// + /// Das war der Fehler, an dem jede Wiedergabe scheiterte. 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. + /// + [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")); + } + + /// Eine kleinere Auflösung wird wirklich kleiner. + [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)); + }); + } + + /// Hochrechnen ist keine Stufe. + [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"); + }); + } + + /// Der Spieler erfährt, welche Tonspur wirklich läuft. + /// + /// + /// Nicht die gewünschte. Vorher meldete der Server zurück, was + /// gefragt worden war — meist null —, und im Menü stand bei keiner + /// Spur ein Haken, obwohl eine davon lief. + /// + [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"); + }); + } + + /// Der Einstiegspunkt kommt zurück, damit der Spieler richtig zählt. + /// + /// + /// Sonst ginge jeder gemeldete Stand um den Vorlauf daneben. Der + /// Strom fängt bei null an, der Film bei Minute 30. + /// + [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)); + } + + /// Schliessen räumt auf — sonst füllt sich der Kratzplatz. + [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"); + }); + } + + /// Ein Pfad aus der Anfrage darf nie ans Dateisystem durchgereicht werden. + [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)); + }); + } + + /// Ohne Anmeldung kommt niemand an einen Strom. + [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)); + } + + /// Eine Sitzung, die es nicht gibt, ist eine 404 und kein Absturz. + [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)); + } +} diff --git a/server/tests/Flaro.Media.Tests/HlsArgumenteTests.cs b/server/tests/Flaro.Media.Tests/HlsArgumenteTests.cs new file mode 100644 index 0000000..3855c4c --- /dev/null +++ b/server/tests/Flaro.Media.Tests/HlsArgumenteTests.cs @@ -0,0 +1,267 @@ +using Flaro.Core.Delivery; +using Flaro.Core.Library; + +namespace Flaro.Media.Tests; + +/// Die ffmpeg-Zeile für einen laufenden Strom, ohne sie auszuführen. +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); + + /// Was ein gewöhnlicher Browser kann. + private static readonly DeviceProfile Browser = new( + Containers: new HashSet(StringComparer.OrdinalIgnoreCase) { "mp4", "webm" }, + VideoCodecs: new HashSet(StringComparer.OrdinalIgnoreCase) { "h264", "avc1", "vp9" }, + AudioCodecs: new HashSet(StringComparer.OrdinalIgnoreCase) { "aac", "mp3", "opus" }); + + private static string Zeile( + IReadOnlyList 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 + // ------------------------------------------------------------------ + + /// Eine MKV mit H.264 wird umgepackt, nicht neu gerechnet. + /// + /// + /// Der häufigste Fall und der billigste. Der Browser kennt die + /// Schachtel nicht, das Bild darin sehr wohl. + /// + [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")); + }); + } + + /// Der Ton wird immer nach AAC gerechnet — das war der Fehler. + /// + /// + /// Hier lag der Grund, aus dem gar nichts lief. 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. + /// + [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 + // ------------------------------------------------------------------ + + /// HEVC kann kein Browser — da hilft Umpacken nichts. + [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")); + }); + } + + /// Ein 4K-Film soll sich auch in 480p ansehen lassen. + [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")); + }); + } + + /// Hochrechnen ist keine Stufe. + /// + /// + /// Ein 720p-Film in „1080p" kostet mehr Last und mehr Daten für kein + /// besseres Bild. Die Stufe wird dann stillschweigend übergangen. + /// + [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")); + }); + } + + /// Eine Spur, die es nicht gibt, macht den Film nicht stumm. + [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")); + } + + /// Der Sprungpunkt steht vor der Eingabe. + /// + /// + /// Hinter -i wäre es ein genauer, aber langsamer Sprung: + /// ffmpeg läse die Datei bis dorthin durch. Bei Minute 90 eines Films + /// dauert das länger als das Ansehen. + /// + [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")); + } + + /// Die Beschleunigung gehört vor die Eingabe, sonst wirkt sie nicht. + [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"))); + }); + } + + /// Untertitel bleiben draussen — sie kommen als eigene Datei. + [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")); + }); + } + + /// Beim Rechnen wird jedes Segment gleich lang. + [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")); + }); + } + + /// Eine Datei ohne Ton bleibt eine Datei ohne Ton. + [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")); + }); + } +} diff --git a/server/tests/Flaro.Media.Tests/HlsLaufTests.cs b/server/tests/Flaro.Media.Tests/HlsLaufTests.cs new file mode 100644 index 0000000..fa3917c --- /dev/null +++ b/server/tests/Flaro.Media.Tests/HlsLaufTests.cs @@ -0,0 +1,225 @@ +using System.Diagnostics; +using Flaro.Core.Delivery; +using Flaro.Core.Library; + +namespace Flaro.Media.Tests; + +/// Dieselbe Zeile, diesmal wirklich ausgeführt. +/// +/// +/// +/// Weil die reine Prüfung nur die Form zeigt. Sie sagt, dass +/// -hls_segment_type fmp4 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. +/// +/// +/// Übersprungen, wo kein ffmpeg liegt. Eine Prüfung, die auf einem +/// Rechner ohne Werkzeug rot wird, sagt nichts über den Code. +/// +/// +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(StringComparer.OrdinalIgnoreCase) { "mp4" }, + VideoCodecs: new HashSet(StringComparer.OrdinalIgnoreCase) { "h264", "avc1" }, + AudioCodecs: new HashSet(StringComparer.OrdinalIgnoreCase) { "aac", "mp3" }); + + /// Führt die gebaute Zeile aus und gibt den Zielordner zurück. + 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; + } + + /// Was wirklich in der Datei steht — nicht, was wir annehmen. + private static List 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(); + 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; + } + + // ------------------------------------------------------------------ + + /// Eine MKV wird umgepackt, und es entstehen abspielbare Segmente. + [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\"")); + }); + } + + /// Was herauskommt, ist H.264 und AAC — und nichts anderes. + /// + /// + /// Das ist der eigentliche Punkt. Die Quelle hat eine Tonspur, die + /// kein Browser kennt; wäre sie kopiert worden, bliebe der Film stumm — + /// und genau so war es vorher. + /// + [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")); + }); + } + + /// Herunterskalieren ändert die Bildhöhe wirklich. + [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)); + } + + /// Ein Sprung mitten hinein liefert ebenfalls Segmente. + [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); + } +} diff --git a/web/app/src/api.ts b/web/app/src/api.ts index c7eeb43..58361f4 100644 --- a/web/app/src/api.ts +++ b/web/app/src/api.ts @@ -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(`/files/${datei}/stream`, { + method: 'POST', body: JSON.stringify(wunsch), + }); + +/** Beendet einen Strom und räumt seinen Kratzplatz weg. */ +export const stromBeenden = (sitzung: string) => + anfrage(`/stream/${sitzung}`, { method: 'DELETE' }); + +export const untertitelAdresse = (datei: string, spur: number) => + `/api/v1/files/${datei}/subtitles/${spur}.vtt`; + /* ---------- Verwaltung ---------- */ export const bibliotheken = () => anfrage('/libraries'); diff --git a/web/app/src/hls-light.d.ts b/web/app/src/hls-light.d.ts new file mode 100644 index 0000000..db11db9 --- /dev/null +++ b/web/app/src/hls-light.d.ts @@ -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'; +} diff --git a/web/app/src/icons.ts b/web/app/src/icons.ts index 6603dcf..5c44a7d 100644 --- a/web/app/src/icons.ts +++ b/web/app/src/icons.ts @@ -50,6 +50,21 @@ export const icons = { + ''), schliessen: rahmen(''), stern: rahmen(''), + + /* --- Spieler -------------------------------------------------------- */ + pause: rahmen('' + + '', true), + ton: rahmen('' + + ''), + stummGeschaltet: rahmen('' + + ''), + vollbild: rahmen('' + + ''), + untertitel: rahmen('' + + ''), + zahnrad: rahmen('' + + ''), } as const; export type IconName = keyof typeof icons; diff --git a/web/app/src/spieler.ts b/web/app/src/spieler.ts new file mode 100644 index 0000000..ee95f22 --- /dev/null +++ b/web/app/src/spieler.ts @@ -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 { + 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 { + 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 { + 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 { + 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 | null = null; + +function hlsLaden(): Promise { + 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 }; diff --git a/web/app/src/style.css b/web/app/src/style.css index d395ecf..15355bb 100644 --- a/web/app/src/style.css +++ b/web/app/src/style.css @@ -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 ---------- */ diff --git a/web/app/src/views/detail.ts b/web/app/src/views/detail.ts index 792dd9d..626bf23 100644 --- a/web/app/src/views/detail.ts +++ b/web/app/src/views/detail.ts @@ -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 { 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 { - 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(); -}