diff --git a/server/Dockerfile b/server/Dockerfile index bbbea60..a600957 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -22,6 +22,28 @@ RUN dotnet publish src/Halo.Api/Halo.Api.csproj -a $TARGETARCH -c Release -o /ap FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime +# ffmpeg und die Treiber für Hardware-Beschleunigung. +# +# **Als eigenes Programm, nicht als Bibliothek.** Halo ruft es als Prozess +# auf; damit bleiben die Lizenzen getrennt (ffmpeg steht unter GPL) und ein +# Absturz dort nimmt den Server nicht mit. +# +# `mesa-va-drivers` bedient AMD und ältere Intel, `intel-media-va-driver` die +# neueren Intel — beide über VAAPI an `/dev/dri`. Welcher greift, entscheidet +# sich zur Laufzeit danach, was durchgereicht wurde. +# +# **Die Intel-Treiber nur auf amd64.** Es gibt sie für arm64 nicht, und ein +# unbedingtes `apt-get install` liess den Bau dort mit Code 100 abbrechen — +# eine Meldung, die nicht verrät, welches der sechs Pakete fehlt. +ARG TARGETARCH +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ffmpeg libva2 libva-drm2 mesa-va-drivers \ + && if [ "$TARGETARCH" = "amd64" ]; then \ + apt-get install -y --no-install-recommends intel-media-va-driver i965-va-driver; \ + fi \ + && rm -rf /var/lib/apt/lists/* + # **Die Verzeichnisse müssen hier angelegt werden, nicht erst beim Start.** # Docker legt den Einhängepunkt eines frischen benannten Volumes sonst als # `root` an und übernimmt für ein leeres Volume die Rechte aus dem Image — diff --git a/server/Halo.slnx b/server/Halo.slnx index 26dcd7e..887226e 100644 --- a/server/Halo.slnx +++ b/server/Halo.slnx @@ -3,11 +3,13 @@ + + diff --git a/server/README.md b/server/README.md index 3788e03..f0f2cd5 100644 --- a/server/README.md +++ b/server/README.md @@ -3,9 +3,10 @@ Ein Medienserver, der dieselben Dinge kann wie Jellyfin — und zwei, die dort nicht gehen. -**Stand: Stufe 2 von 5.** Benutzer, Anmeldung, Einrichtung, und er liest den -Bestand: Dateien finden, Namen erkennen, Metadaten und Bilder von TMDB holen. -Abspielen kann er noch nicht — das ist Stufe 3. Der Entwurf samt Begründungen steht +**Stand: Stufe 3 von 5.** Benutzer, Anmeldung, Einrichtung, Bestand einlesen, +Metadaten — und **Download mit allen Ton- und Textspuren**, der Grund für +dieses Projekt. Was noch fehlt: Streamen mit Umrechnen (HLS), Fortschritt und +Gesehen-Status, Live TV. Der Entwurf samt Begründungen steht in [`docs/superpowers/specs/2026-10-03-halo-server-design.md`](../docs/superpowers/specs/2026-10-03-halo-server-design.md). ## Warum @@ -67,7 +68,7 @@ hilft. ## Entwickeln ```bash -dotnet test # 180 Tests +dotnet test # 223 Tests dotnet run --project src/Halo.Api # auf http://localhost:5000 ``` @@ -91,6 +92,7 @@ Kommando. |---|---| | `Halo.Core` | Domäne: Benutzer, Anmeldung, Bibliotheken. Kennt kein HTTP und keine Datenbank | | `Halo.Data` | EF Core, SQLite, Migrationen | +| `Halo.Media` | ffprobe und ffmpeg. Als eigene Prozesse, nicht eingebunden | | `Halo.Metadata` | TMDB und die Bildablage. Hinter einer Schnittstelle, damit alles andere ohne Netz prüfbar bleibt | | `Halo.Api` | Endpunkte, Authentifizierung, Auslieferung der Oberfläche | @@ -139,6 +141,41 @@ Bilder werden einmal heruntergeladen und liegen unter `HALO_CACHE`. Die App holt sie bei Halo, nicht bei TMDB: sonst bräuchte sie dafür Internet, und TMDB erführe, wer wann was ansieht. +## Herunterladen — mit allen Spuren + +``` +GET /api/v1/files/{id}/delivery was passieren wird, bevor etwas passiert +GET /api/v1/files/{id}/download die Datei — oder 202, solange umgepackt wird +``` + +**`delivery` zuerst fragen.** Dort steht, ob die Datei unverändert hinausgeht +oder umgepackt wird, welche Tonspuren dabei neu gerechnet werden müssen und +welche Untertitel nicht mitkönnen. Drei Gigabyte zu laden und erst danach zu +merken, dass die Untertitel fehlen, war der Anlass für dieses Projekt. + +**Alle Tonspuren kommen mit.** Der Plan hat kein Feld „welche Tonspur" — es +gibt nichts auszuwählen, weil alle mitgehen. Sprachkennungen und Spurnamen +überstehen das Umpacken; nachgewiesen an einer Datei mit „Deutsch" und +„Audiodeskription", die sich allein am Namen unterscheiden. + +**Das Bild wird nie angefasst.** Es wandert Byte für Byte in die MP4. Nur +Tonspuren in Codecs, die das Gerät nicht kennt — DTS, TrueHD —, werden neu +gerechnet, und auch dann keine einzige Bildzeile. + +**Bildbasierte Untertitel fallen weg.** PGS und VobSub passen in keine MP4. +Das steht in `delivery` unter `subtitlesDropped`, statt stillschweigend zu +geschehen. + +**Umgepackt wird auf die Platte, nicht in die Antwort.** Der bequeme Weg wäre, +ffmpeg unmittelbar durchzureichen — dann kennt aber niemand die Länge im +Voraus: kein Fortschrittsbalken, kein Fortsetzen, kein Springen. Dafür gibt es +`HALO_TRANSCODE`. + +| Variable | Bedeutung | Voreinstellung | +|---|---|---| +| `HALO_FFMPEG` | Pfad zu ffmpeg | `ffmpeg` | +| `HALO_FFPROBE` | Pfad zu ffprobe | `ffprobe` | + ## Anmeldung am Fernseher Ein Passwort auf der Apple-TV-Fernbedienung zu tippen ist eine Zumutung. diff --git a/server/src/Halo.Api/Endpoints/Contracts.cs b/server/src/Halo.Api/Endpoints/Contracts.cs index d161692..b35520d 100644 --- a/server/src/Halo.Api/Endpoints/Contracts.cs +++ b/server/src/Halo.Api/Endpoints/Contracts.cs @@ -45,6 +45,7 @@ public sealed record ScanStatus( int? Updated, int? Removed, int? Unrecognised, + int? Probed, IReadOnlyList? Problems, string? Error, MetadataProgress? Metadata); @@ -53,7 +54,22 @@ public sealed record ScanStatus( public sealed record MetadataProgress( int MoviesMatched, int SeriesMatched, int EpisodesMatched, bool Skipped, string? Error); -public sealed record MediaFileResponse(Guid Id, string Path, long SizeBytes); +/// Eine Spur in einer Datei. +public sealed record MediaStreamResponse( + int Index, string Kind, string? Codec, string? Language, string? Title, + bool IsDefault, bool IsForced, int? Channels, int? Width, int? Height); + +/// Eine Datei hinter einem Titel — eine Fassung also. +/// +/// +/// Nur der Dateiname, nicht der Pfad. Der Pfad verriete jedem +/// Angemeldeten den Aufbau des Servers, und gebraucht wird er nirgends: die +/// App spricht Dateien über ihre Kennung an. Zum Unterscheiden zweier +/// Fassungen genügt der Name. +/// +public sealed record MediaFileResponse( + Guid Id, string FileName, long SizeBytes, string? Container, double? DurationSeconds, + IReadOnlyList Streams); public sealed record MovieResponse( Guid Id, string Title, int? Year, string? Overview, @@ -74,3 +90,21 @@ public sealed record Page(IReadOnlyList Items, int Total); public sealed record MetadataSettingsResponse(bool HasApiKey, string Language); public sealed record MetadataSettingsRequest(string? ApiKey, string? Language); + +// --- Stufe 3: Ausliefern ------------------------------------------------- + +/// Was mit einer Datei geschehen wird, bevor sie hinausgeht. +/// +/// +/// Damit vorher feststeht, was man bekommt. Drei Gigabyte +/// herunterzuladen und erst danach zu merken, dass die Untertitel fehlen oder +/// die falsche Tonspur drin ist, war der Anlass für dieses ganze Projekt. +/// +public sealed record DeliveryInfo( + string Method, + IReadOnlyList Reasons, + IReadOnlyList AudioToEncode, + IReadOnlyList SubtitlesDropped, + string PackageState, + double Progress, + string? Error); diff --git a/server/src/Halo.Api/Endpoints/DeliveryEndpoints.cs b/server/src/Halo.Api/Endpoints/DeliveryEndpoints.cs new file mode 100644 index 0000000..aa90ad2 --- /dev/null +++ b/server/src/Halo.Api/Endpoints/DeliveryEndpoints.cs @@ -0,0 +1,105 @@ +using Halo.Core.Delivery; +using Halo.Core.Library; +using Halo.Data; +using Microsoft.EntityFrameworkCore; + +namespace Halo.Api.Endpoints; + +public static class DeliveryEndpoints +{ + public static void MapDelivery(this IEndpointRouteBuilder app) + { + app.MapGet("/api/v1/files/{id:guid}/delivery", async ( + Guid id, HaloContext db, PackageCoordinator packer) => + { + var gefunden = await Laden(db, id); + if (gefunden is not var (datei, plan)) return Results.NotFound(); + + return Results.Ok(Auskunft(plan, packer, datei.Id, datei.ModifiedAt)); + }).RequireAuthorization(); + + app.MapGet("/api/v1/files/{id:guid}/download", async ( + Guid id, HaloContext db, PackageCoordinator packer) => + { + var gefunden = await Laden(db, id); + if (gefunden is not var (datei, plan)) return Results.NotFound(); + + // Passt schon alles, geht die Datei unverändert hinaus — mit + // Längenangabe und Bereichsanfragen, also mit Fortschritt und + // Fortsetzen. + if (!plan.NeedsWork) + { + return File.Exists(datei.Path) + ? Results.File(datei.Path, "video/mp4", Path.GetFileName(datei.Path), + enableRangeProcessing: true) + : Results.Problem($"„{Path.GetFileName(datei.Path)}“ liegt nicht mehr da.", + statusCode: StatusCodes.Status410Gone); + } + + var fertig = packer.PathFor(datei.Id, datei.ModifiedAt); + if (File.Exists(fertig)) + { + return Results.File(fertig, "video/mp4", + Path.ChangeExtension(Path.GetFileName(datei.Path), ".mp4"), + enableRangeProcessing: true); + } + + var stand = packer.State(datei.Id); + if (stand?.Status == "failed") + { + return Results.Problem( + stand.Error ?? "Das Umpacken ist gescheitert.", + statusCode: StatusCodes.Status500InternalServerError); + } + + packer.TryStart(datei.Id); + + // **202 und kein Warten.** Das Umpacken eines grossen Films dauert + // Minuten; eine so lange offene Anfrage läuft hinter jedem Reverse + // Proxy ins Zeitlimit. Der Client fragt stattdessen nach und holt + // die Datei, wenn sie fertig ist. + return Results.Json( + Auskunft(plan, packer, datei.Id, datei.ModifiedAt), + statusCode: StatusCodes.Status202Accepted); + }).RequireAuthorization(); + } + + private static async Task<(MediaFile Datei, DeliveryPlan Plan)?> Laden(HaloContext db, Guid id) + { + var datei = await db.MediaFiles.AsNoTracking().FirstOrDefaultAsync(f => f.Id == id); + if (datei is null) 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 (datei, DeliveryDecider.ForDownload(datei.Container, gelesen)); + } + + private static DeliveryInfo Auskunft( + DeliveryPlan plan, PackageCoordinator packer, Guid dateiId, DateTimeOffset geändert) + { + var stand = !plan.NeedsWork ? "notNeeded" + : packer.IsReady(dateiId, geändert) ? "ready" + : packer.State(dateiId)?.Status ?? "idle"; + + return new DeliveryInfo( + Method: plan.Method switch + { + DeliveryMethod.DirectFile => "directFile", + DeliveryMethod.Remux => "remux", + _ => "unknown", + }, + Reasons: plan.Reasons.Select(g => Kleinbuchstabe(g.ToString())).ToList(), + AudioToEncode: plan.AudioToEncode, + SubtitlesDropped: plan.SubtitlesDropped, + PackageState: stand, + Progress: packer.State(dateiId)?.Progress ?? (stand == "ready" ? 1 : 0), + Error: packer.State(dateiId)?.Error); + } + + private static string Kleinbuchstabe(string name) => char.ToLowerInvariant(name[0]) + name[1..]; +} diff --git a/server/src/Halo.Api/Endpoints/LibraryContentEndpoints.cs b/server/src/Halo.Api/Endpoints/LibraryContentEndpoints.cs index 2d20a8b..5a6ce4d 100644 --- a/server/src/Halo.Api/Endpoints/LibraryContentEndpoints.cs +++ b/server/src/Halo.Api/Endpoints/LibraryContentEndpoints.cs @@ -20,6 +20,31 @@ public static class LibraryContentEndpoints MapBrowse(app); } + /// Holt die Spuren aller genannten Dateien in einer Abfrage. + /// + /// + /// Je Datei einzeln zu fragen hiesse: fünfzig Titel, fünfzig Abfragen — + /// und SQLite führt die nacheinander aus. + /// + private static async Task> SpurenZu(HaloContext db, List dateien) + { + if (dateien.Count == 0) return Array.Empty<(Guid, MediaStreamResponse)>().ToLookup(x => x.Item1, x => x.Item2); + + var roh = await db.MediaStreams.AsNoTracking() + .Where(s => dateien.Contains(s.MediaFileId)) + .OrderBy(s => s.Index) + .ToListAsync(); + + return roh.ToLookup(s => s.MediaFileId, s => new MediaStreamResponse( + s.Index, s.Kind.ToString(), s.Codec, s.Language, s.Title, + s.IsDefault, s.IsForced, s.Channels, s.Width, s.Height)); + } + + private static MediaFileResponse Datei( + Guid id, string pfad, long bytes, string? schachtel, TimeSpan? dauer, + ILookup spuren) => + new(id, System.IO.Path.GetFileName(pfad), bytes, schachtel, dauer?.TotalSeconds, spuren[id].ToList()); + private static void MapScan(IEndpointRouteBuilder app) { app.MapPost("/api/v1/libraries/{id:guid}/scan", async ( @@ -38,13 +63,14 @@ public static class LibraryContentEndpoints { var zustand = koordinator.State(id); if (zustand is null) - return Results.Ok(new ScanStatus("idle", null, null, null, null, null, null, null, null, null, null)); + return Results.Ok(new ScanStatus("idle", null, null, null, null, null, null, null, null, null, null, null)); var bericht = zustand.Report; var metadaten = zustand.Enrichment; return Results.Ok(new ScanStatus( zustand.Status, zustand.StartedAt, zustand.FinishedAt, bericht?.FilesFound, bericht?.Added, bericht?.Updated, bericht?.Removed, bericht?.Unrecognised, + bericht?.Probed, bericht?.Problems.Select(p => new ScanProblem(p.Path, p.Reason)).ToList(), zustand.Error, metadaten is null ? null : new MetadataProgress( @@ -76,13 +102,14 @@ public static class LibraryContentEndpoints var ids = kennungen.Select(m => m.Id).ToList(); var dateien = await db.MediaFiles.AsNoTracking() .Where(f => f.MovieId != null && ids.Contains(f.MovieId!.Value)) - .Select(f => new { f.Id, f.Path, f.SizeBytes, f.MovieId }) + .Select(f => new { f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, f.MovieId }) .ToListAsync(); + var spuren = await SpurenZu(db, dateien.Select(f => f.Id).ToList()); var items = kennungen.Select(m => new MovieResponse( m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage, dateien.Where(f => f.MovieId == m.Id) - .Select(f => new MediaFileResponse(f.Id, f.Path, f.SizeBytes)).ToList())).ToList(); + .Select(f => Datei(f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, spuren)).ToList())).ToList(); return Results.Ok(new Page(items, gesamt)); }).RequireAuthorization(); @@ -134,13 +161,14 @@ public static class LibraryContentEndpoints var ids = folgen.Select(f => f.Id).ToList(); var dateien = await db.MediaFiles.AsNoTracking() .Where(d => d.EpisodeId != null && ids.Contains(d.EpisodeId!.Value)) - .Select(d => new { d.Id, d.Path, d.SizeBytes, d.EpisodeId }) + .Select(d => new { d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, d.EpisodeId }) .ToListAsync(); + var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList()); var items = folgen.Select(f => new EpisodeResponse( f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview, f.StillImage, dateien.Where(d => d.EpisodeId == f.Id) - .Select(d => new MediaFileResponse(d.Id, d.Path, d.SizeBytes)).ToList())).ToList(); + .Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren)).ToList())).ToList(); return Results.Ok(new Page(items, items.Count)); }).RequireAuthorization(); diff --git a/server/src/Halo.Api/Halo.Api.csproj b/server/src/Halo.Api/Halo.Api.csproj index af0984b..ccea93a 100644 --- a/server/src/Halo.Api/Halo.Api.csproj +++ b/server/src/Halo.Api/Halo.Api.csproj @@ -4,6 +4,7 @@ + diff --git a/server/src/Halo.Api/PackageCoordinator.cs b/server/src/Halo.Api/PackageCoordinator.cs new file mode 100644 index 0000000..a05e56c --- /dev/null +++ b/server/src/Halo.Api/PackageCoordinator.cs @@ -0,0 +1,156 @@ +using System.Collections.Concurrent; +using Halo.Core.Delivery; +using Halo.Core.Library; +using Halo.Data; +using Halo.Media; +using Microsoft.EntityFrameworkCore; + +namespace Halo.Api; + +public sealed record PackageState( + string Status, + double Progress, + DateTimeOffset StartedAt, + DateTimeOffset? FinishedAt, + string? Error); + +/// Packt Dateien im Hintergrund um und weiss, wie weit das ist. +/// +/// +/// +/// Erst auf die Platte, dann hinaus — nicht unmittelbar durchgereicht. +/// Der bequeme Weg wäre, ffmpeg in die Antwort schreiben zu lassen. Dann +/// kennt aber niemand die Länge im Voraus: kein Fortschrittsbalken, kein +/// Fortsetzen nach einem Abbruch, kein Springen in der fertigen Datei. Genau +/// daran ist das Herunterladen schon einmal gescheitert — bei drei Gigabyte +/// über Mobilfunk ist „von vorn" keine Antwort. +/// +/// +/// Der Preis ist Plattenplatz und Wartezeit vor dem ersten Byte. Dafür gibt +/// es HALO_TRANSCODE, und dafür meldet dieser Weg einen Fortschritt. +/// +/// +public sealed class PackageCoordinator( + IServiceScopeFactory scopes, + HaloPaths pfade, + TimeProvider clock, + ILogger log) +{ + private readonly ConcurrentDictionary _zustand = new(); + + /// Wie viel freier Platz über die Dateigrösse hinaus bleiben muss. + /// + /// + /// Eine volle Platte ist der unangenehmste Ausgang: ffmpeg bricht + /// irgendwann ab, und nebenher kann der Server seine Datenbank nicht mehr + /// schreiben. Lieber vorher ablehnen und sagen, woran es liegt. + /// + private const long Sicherheitsabstand = 2L * 1024 * 1024 * 1024; + + public PackageState? State(Guid fileId) => _zustand.GetValueOrDefault(fileId); + + /// Wohin die umgepackte Datei kommt. + /// + /// + /// Der Änderungszeitpunkt steckt im Namen. Wird die Quelle ersetzt, + /// heisst das Ergebnis anders — und niemand bekommt die alte Fassung + /// ausgeliefert, bloss weil sie zufällig noch herumliegt. + /// + public string PathFor(Guid fileId, DateTimeOffset geändert) => + Path.Combine(pfade.Transcode, "packages", $"{fileId:N}-{geändert.ToUnixTimeMilliseconds()}.mp4"); + + public bool IsReady(Guid fileId, DateTimeOffset geändert) => File.Exists(PathFor(fileId, geändert)); + + /// Startet einen Lauf — oder sagt, dass schon einer läuft. + public bool TryStart(Guid fileId) + { + var start = new PackageState("running", 0, clock.GetUtcNow(), null, null); + var vorher = _zustand.AddOrUpdate(fileId, start, (_, alt) => alt.Status == "running" ? alt : start); + if (!ReferenceEquals(vorher, start)) return false; + + _ = Task.Run(() => Laufen(fileId)); + return true; + } + + private async Task Laufen(Guid fileId) + { + try + { + using var bereich = scopes.CreateScope(); + var db = bereich.ServiceProvider.GetRequiredService(); + + var datei = await db.MediaFiles.AsNoTracking().FirstOrDefaultAsync(f => f.Id == fileId) + ?? 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."); + + PlatzPruefen(datei.SizeBytes); + + var spuren = await db.MediaStreams.AsNoTracking() + .Where(s => s.MediaFileId == fileId).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(); + + var plan = DeliveryDecider.ForDownload(datei.Container, gelesen); + var ziel = PathFor(fileId, datei.ModifiedAt); + Directory.CreateDirectory(Path.GetDirectoryName(ziel)!); + AelteresWegraeumen(fileId, ziel); + + var fortschritt = new Progress(wert => + _zustand.AddOrUpdate(fileId, + _ => new PackageState("running", wert, clock.GetUtcNow(), null, null), + (_, alt) => alt with { Progress = wert })); + + var umpacker = bereich.ServiceProvider.GetRequiredService(); + var ergebnis = await umpacker.RunAsync( + new RemuxRequest(datei.Path, ziel, gelesen, plan), datei.Duration, fortschritt); + + _zustand[fileId] = ergebnis.Success + ? new PackageState("ready", 1, _zustand[fileId].StartedAt, clock.GetUtcNow(), null) + : new PackageState("failed", 0, _zustand[fileId].StartedAt, clock.GetUtcNow(), ergebnis.Error); + } + catch (Exception fehler) + { + log.LogError(fehler, "Das Umpacken von {Kennung} ist gescheitert.", fileId); + _zustand[fileId] = new PackageState( + "failed", 0, _zustand[fileId].StartedAt, clock.GetUtcNow(), fehler.Message); + } + } + + /// Bricht ab, bevor die Platte volläuft. + private void PlatzPruefen(long quellgrösse) + { + try + { + var platte = new DriveInfo(Path.GetPathRoot(Path.GetFullPath(pfade.Transcode)) ?? "/"); + var gebraucht = quellgrösse + Sicherheitsabstand; + if (platte.AvailableFreeSpace >= gebraucht) return; + + throw new InvalidOperationException( + $"Auf dem Kratzplatz ({pfade.Transcode}) sind noch " + + $"{platte.AvailableFreeSpace / (1024 * 1024)} MB frei, gebraucht werden etwa " + + $"{gebraucht / (1024 * 1024)} MB. Räume dort auf oder hänge ein grösseres Volume ein."); + } + catch (Exception fehler) when (fehler is ArgumentException or IOException or UnauthorizedAccessException) + { + // Lässt sich der freie Platz nicht ermitteln, wird es versucht. + // Eine Vorsichtsprüfung darf nicht selbst zum Hindernis werden. + log.LogDebug(fehler, "Der freie Platz auf {Pfad} liess sich nicht ermitteln.", pfade.Transcode); + } + } + + /// Löscht frühere Fassungen derselben Datei. + private void AelteresWegraeumen(Guid fileId, string behalten) + { + var ordner = Path.GetDirectoryName(behalten)!; + foreach (var alt in Directory.EnumerateFiles(ordner, $"{fileId:N}-*.mp4")) + { + if (string.Equals(alt, behalten, StringComparison.Ordinal)) continue; + try { File.Delete(alt); } + catch (IOException fehler) { log.LogDebug(fehler, "{Datei} liess sich nicht löschen.", alt); } + } + } +} diff --git a/server/src/Halo.Api/Program.cs b/server/src/Halo.Api/Program.cs index cd338b1..f51b1ec 100644 --- a/server/src/Halo.Api/Program.cs +++ b/server/src/Halo.Api/Program.cs @@ -4,6 +4,8 @@ using Halo.Api.Auth; using Halo.Api.Endpoints; using Halo.Core.Metadata; using Halo.Data; +using Halo.Core.Library; +using Halo.Media; using Halo.Metadata; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.RateLimiting; @@ -59,6 +61,12 @@ builder.Services.AddRateLimiter(o => _ => new FixedWindowRateLimiterOptions { PermitLimit = beimNachfragen, Window = TimeSpan.FromMinutes(1) })); }); +// ffmpeg als eigener Prozess, nicht eingebunden: hält die Lizenzen getrennt +// und lässt einen Absturz dort den Server nicht mitnehmen. +builder.Services.AddSingleton(FfmpegOptions.FromEnvironment()); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + builder.Services.AddScoped(); // Metadaten. Der Anbieter steckt hinter einer Schnittstelle, damit alles, was @@ -76,6 +84,7 @@ builder.Services.AddHttpClient(c => c.Timeout = TimeSpan.FromSeconds builder.Services.AddScoped(sp => sp.GetRequiredService()); builder.Services.AddScoped(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddProblemDetails(); var app = builder.Build(); @@ -99,6 +108,7 @@ app.MapManagement(); app.MapLibraryContent(); app.MapSettings(); app.MapImages(); +app.MapDelivery(); app.Run(); diff --git a/server/src/Halo.Core/Delivery/DeliveryDecider.cs b/server/src/Halo.Core/Delivery/DeliveryDecider.cs new file mode 100644 index 0000000..e5ce7a4 --- /dev/null +++ b/server/src/Halo.Core/Delivery/DeliveryDecider.cs @@ -0,0 +1,136 @@ +using Halo.Core.Library; + +namespace Halo.Core.Delivery; + +public enum DeliveryMethod +{ + /// Die Datei geht unverändert hinaus. + DirectFile, + + /// Die Spuren wandern in eine MP4 — Bild und meist auch Ton unberührt. + Remux, +} + +public enum DeliveryReason +{ + NativelySupported, + ContainerUnsupported, + VideoCodecUnsupported, + AudioCodecUnsupported, + + /// Untertitel müssen für MP4 nach tx3g umgeschrieben werden. + SubtitlesNeedConversion, + + /// Bildbasierte Untertitel passen in keine MP4 und fallen weg. + ImageSubtitlesDropped, +} + +/// Was mit einer Datei geschehen muss, damit das Gerät sie öffnet. +public sealed record DeliveryPlan( + DeliveryMethod Method, + IReadOnlyList Reasons, + + /// Tonspuren, die neu kodiert werden müssen — Nummern wie in der Datei. + IReadOnlyList AudioToEncode, + + /// Textspuren, die nicht mitkönnen. + IReadOnlyList SubtitlesDropped) +{ + public bool NeedsWork => Method != DeliveryMethod.DirectFile; +} + +/// Entscheidet, ob eine Datei unverändert hinausgehen kann. +/// +/// +/// +/// Das Bild wird nie angefasst. Beim Umpacken wandert es Byte für Byte +/// in die neue Schachtel; das kostet den Server fast nichts. Nur Tonspuren in +/// Codecs, die das Gerät nicht kennt, werden neu gerechnet — und auch dann +/// keine einzige Bildzeile. +/// +/// +/// Was fehlt, zählt als passend. Nennt die Datei keinen Codec, ist das +/// keine Aussage über seine Untauglichkeit. Vorsichtshalber umzupacken hiesse, +/// aus einer tadellosen MP4 eine neue Datei zu machen. +/// +/// +public static class DeliveryDecider +{ + /// Untertitel, die als Bild vorliegen — MP4 hat dafür keinen Platz. + /// + /// + /// Betrifft fast jeden Blu-ray-Rip. PGS ist dort die Regel. Diese + /// Spuren fallen beim Umpacken weg, und das muss gesagt werden, statt + /// stillschweigend zu geschehen — sonst sucht jemand nach Untertiteln, die + /// der Server bewusst weggelassen hat. + /// + private static readonly HashSet BildUntertitel = new(StringComparer.OrdinalIgnoreCase) + { "hdmv_pgs_subtitle", "pgssub", "dvd_subtitle", "dvdsub", "xsub" }; + + /// Textuntertitel, die sich nach tx3g umschreiben lassen. + private static readonly HashSet TextUntertitel = new(StringComparer.OrdinalIgnoreCase) + { "subrip", "srt", "ass", "ssa", "mov_text", "webvtt", "text" }; + + public static DeliveryPlan ForDownload( + string? container, IReadOnlyList spuren, DeviceProfile? profil = null) + { + profil ??= DeviceProfile.AppleDefault; + + var gründe = new List(); + var neuZuRechnen = new List(); + var fallenWeg = new List(); + + var schachtelPasst = Passt(container, profil.Containers); + if (!schachtelPasst) gründe.Add(DeliveryReason.ContainerUnsupported); + + foreach (var spur in spuren) + { + switch (spur.Kind) + { + case MediaStreamKind.Video when !Passt(spur.Codec, profil.VideoCodecs): + gründe.Add(DeliveryReason.VideoCodecUnsupported); + break; + + case MediaStreamKind.Audio when !Passt(spur.Codec, profil.AudioCodecs): + gründe.Add(DeliveryReason.AudioCodecUnsupported); + neuZuRechnen.Add(spur.Index); + break; + + case MediaStreamKind.Subtitle when spur.Codec is { } codec && BildUntertitel.Contains(codec): + fallenWeg.Add(spur.Index); + break; + + case MediaStreamKind.Subtitle when spur.Codec is { } codec + && TextUntertitel.Contains(codec) + && !codec.Equals("mov_text", StringComparison.OrdinalIgnoreCase): + gründe.Add(DeliveryReason.SubtitlesNeedConversion); + break; + } + } + + if (fallenWeg.Count > 0) gründe.Add(DeliveryReason.ImageSubtitlesDropped); + + // Alles passt, und nichts fällt weg: die Datei geht, wie sie ist. + var mussUmgepackt = !schachtelPasst + || gründe.Contains(DeliveryReason.VideoCodecUnsupported) + || neuZuRechnen.Count > 0 + || gründe.Contains(DeliveryReason.SubtitlesNeedConversion); + + if (!mussUmgepackt) + { + // Bildbasierte Untertitel in einer MP4 wären schon beim Erzeugen + // der Datei weggefallen — sie können hier also gar nicht stehen. + return new DeliveryPlan( + DeliveryMethod.DirectFile, [DeliveryReason.NativelySupported], [], fallenWeg); + } + + return new DeliveryPlan( + DeliveryMethod.Remux, Eindeutig(gründe), neuZuRechnen, fallenWeg); + } + + private static bool Passt(string? wert, IReadOnlySet erlaubt) => + string.IsNullOrWhiteSpace(wert) || erlaubt.Contains(wert); + + private static List Eindeutig(List gründe) => + gründe.Distinct().ToList(); +} diff --git a/server/src/Halo.Core/Delivery/DeviceProfile.cs b/server/src/Halo.Core/Delivery/DeviceProfile.cs new file mode 100644 index 0000000..f30067f --- /dev/null +++ b/server/src/Halo.Core/Delivery/DeviceProfile.cs @@ -0,0 +1,41 @@ +namespace Halo.Core.Delivery; + +/// Was ein Gerät von sich aus öffnen kann. +/// +/// +/// +/// Diese Liste steht hier ein zweites Mal. Dieselbe Aufstellung führt +/// die App in Swift, und solange Server und Client verschiedene Sprachen +/// sprechen, lässt sie sich nicht teilen. Das ist der bewusst gezahlte Preis +/// für eine eigene Schnittstelle — und der Grund, warum beide Seiten +/// Prüfungen dazu haben: auseinanderlaufen würden sie sonst unbemerkt. +/// +/// +/// Der Client schickt sein Profil künftig selbst mit. Bis dahin gilt +/// , und das ist keine Verlegenheit: es ist genau +/// die Liste, an der sich entscheidet, ob umgepackt werden muss. +/// +/// +public sealed record DeviceProfile( + IReadOnlySet Containers, + IReadOnlySet VideoCodecs, + IReadOnlySet AudioCodecs) +{ + /// Was AVFoundation auf iOS, tvOS und macOS öffnet. + /// + /// + /// Kein mkv. Das ist keine Auslassung, sondern der Kern des + /// Problems: Matroska ist die übliche Schachtel für Remuxe, und + /// AVFoundation öffnet sie nicht — obwohl Bild und Ton darin oft genau + /// das sind, was sie bestens abspielt. + /// + /// Kein DTS, kein TrueHD, kein Vorbis. Die drei Gründe, warum ein + /// sonst tadelloser Film stumm bliebe. + /// + public static readonly DeviceProfile AppleDefault = new( + Containers: new HashSet(StringComparer.OrdinalIgnoreCase) { "mp4", "m4v", "mov" }, + VideoCodecs: new HashSet(StringComparer.OrdinalIgnoreCase) + { "h264", "avc1", "hevc", "h265", "hvc1", "dvh1", "dvhe" }, + AudioCodecs: new HashSet(StringComparer.OrdinalIgnoreCase) + { "aac", "mp3", "ac3", "eac3", "alac", "pcm", "lpcm", "flac" }); +} diff --git a/server/src/Halo.Core/Library/IMediaProbe.cs b/server/src/Halo.Core/Library/IMediaProbe.cs new file mode 100644 index 0000000..da44104 --- /dev/null +++ b/server/src/Halo.Core/Library/IMediaProbe.cs @@ -0,0 +1,33 @@ +namespace Halo.Core.Library; + +/// Was in einer Datei steckt. +public sealed record ProbeResult( + string? Container, + TimeSpan? Duration, + long? BitRate, + IReadOnlyList Streams); + +public sealed record ProbedStream( + int Index, + MediaStreamKind Kind, + string? Codec, + string? Language, + string? Title, + bool IsDefault, + bool IsForced, + int? Channels, + int? Width, + int? Height); + +/// Liest die technischen Angaben einer Datei. +/// +/// +/// Als Schnittstelle, damit alles darauf Aufbauende ohne ffmpeg prüfbar +/// bleibt — aus demselben Grund wie beim Metadaten-Anbieter. null +/// heisst: nicht lesbar. Das ist kein Abbruch, sondern eine Datei, über die +/// nichts bekannt ist. +/// +public interface IMediaProbe +{ + Task ProbeAsync(string pfad, CancellationToken ct = default); +} diff --git a/server/src/Halo.Core/Library/MediaEntities.cs b/server/src/Halo.Core/Library/MediaEntities.cs index 0d9b315..fe268bb 100644 --- a/server/src/Halo.Core/Library/MediaEntities.cs +++ b/server/src/Halo.Core/Library/MediaEntities.cs @@ -35,6 +35,24 @@ public sealed class MediaFile public Guid? MovieId { get; set; } public Guid? EpisodeId { get; set; } + + // --- Was ffprobe gelesen hat ----------------------------------------- + + /// Die Schachtel: mkv, mp4, ts … + public string? Container { get; set; } + + public TimeSpan? Duration { get; set; } + public long? BitRate { get; set; } + + /// Wann zuletzt untersucht — null, solange nie. + /// + /// + /// Daran hängt, ob der nächste Scan die Datei erneut untersucht. Ein + /// Durchgang kostet rund fünfzig Millisekunden; bei vierzigtausend Dateien + /// sind das über eine halbe Stunde, und die will niemand bei jedem Lauf + /// erneut aufbringen. + /// + public DateTimeOffset? ProbedAt { get; set; } } public sealed class Movie diff --git a/server/src/Halo.Core/Library/MediaStreams.cs b/server/src/Halo.Core/Library/MediaStreams.cs new file mode 100644 index 0000000..a9da49d --- /dev/null +++ b/server/src/Halo.Core/Library/MediaStreams.cs @@ -0,0 +1,59 @@ +namespace Halo.Core.Library; + +public enum MediaStreamKind { Video, Audio, Subtitle } + +/// Eine Spur in einer Datei, so wie ffprobe sie beschreibt. +/// +/// +/// +/// Hier entscheidet sich alles Weitere. Ob eine Datei unverändert +/// laufen kann, ob sie nur umgepackt werden muss oder ob gerechnet werden +/// muss, hängt an Schachtel und Codecs. Und die Frage, die das ganze Projekt +/// ausgelöst hat — welche Tonspuren gibt es überhaupt — ist ohne diese +/// Tabelle nicht zu beantworten. +/// +/// +public sealed class MediaStream +{ + public Guid Id { get; init; } = Guid.CreateVersion7(); + public required Guid MediaFileId { get; init; } + + /// Die Nummer, unter der ffmpeg die Spur anspricht. + /// + /// + /// Nicht fortlaufend je Art: in einer Datei mit Bild, zwei Tonspuren und + /// einem Untertitel sind das 0 bis 3. Wer hier eine eigene Zählung führt, + /// baut dem späteren -map 0:a:1 eine Falle. + /// + public required int Index { get; init; } + + public required MediaStreamKind Kind { get; init; } + public string? Codec { get; init; } + + /// Die Sprache, wie die Datei sie führt — meist dreibuchstabig. + /// + /// + /// null oder und kommt oft vor und ist der Grund, warum beim + /// Herunterladen überhaupt gefragt werden muss: ohne Sprachkennung lässt + /// sich „deutsch" nicht von „deutsch mit Audiodeskription" unterscheiden. + /// + public string? Language { get; init; } + + /// Der Name der Spur, falls die Datei einen führt. + /// + /// + /// Oft die einzige Stelle, an der „Audiodeskription" oder „Kommentar" + /// steht — die Sprachkennung sagt bei beiden nur „deu". + /// + public string? Title { get; init; } + + public bool IsDefault { get; init; } + public bool IsForced { get; init; } + + /// Nur bei Ton: wie viele Kanäle. + public int? Channels { get; init; } + + /// Nur bei Bild. + public int? Width { get; init; } + public int? Height { get; init; } +} diff --git a/server/src/Halo.Data/Converters.cs b/server/src/Halo.Data/Converters.cs index dbda9c5..feab212 100644 --- a/server/src/Halo.Data/Converters.cs +++ b/server/src/Halo.Data/Converters.cs @@ -28,3 +28,19 @@ public sealed class DateTimeOffsetToUnixMilliseconds() : ValueConverter( zeitpunkt => zeitpunkt == null ? null : zeitpunkt.Value.ToUnixTimeMilliseconds(), zahl => zahl == null ? null : DateTimeOffset.FromUnixTimeMilliseconds(zahl.Value)); + +/// Zeitspannen als Millisekunden. +/// +/// +/// Aus demselben Grund wie bei den Zeitpunkten: SQLite legt eine +/// TimeSpan sonst als Text ab, und darauf lässt sich nicht sinnvoll +/// vergleichen oder sortieren — „nach Länge sortieren" wäre damit unmöglich. +/// +public sealed class TimeSpanToMilliseconds() : ValueConverter( + spanne => (long)spanne.TotalMilliseconds, + zahl => TimeSpan.FromMilliseconds(zahl)); + +/// +public sealed class NullableTimeSpanToMilliseconds() : ValueConverter( + spanne => spanne == null ? null : (long)spanne.Value.TotalMilliseconds, + zahl => zahl == null ? null : TimeSpan.FromMilliseconds(zahl.Value)); diff --git a/server/src/Halo.Data/HaloContext.cs b/server/src/Halo.Data/HaloContext.cs index ab17745..9f478c4 100644 --- a/server/src/Halo.Data/HaloContext.cs +++ b/server/src/Halo.Data/HaloContext.cs @@ -31,6 +31,7 @@ public sealed class HaloContext(DbContextOptions options) : DbConte public DbSet Movies => Set(); public DbSet Series => Set(); public DbSet Episodes => Set(); + public DbSet MediaStreams => Set(); /// Gilt für jede Zeitangabe im Modell, ohne sie einzeln zu nennen. /// @@ -43,6 +44,8 @@ public sealed class HaloContext(DbContextOptions options) : DbConte { builder.Properties().HaveConversion(); builder.Properties().HaveConversion(); + builder.Properties().HaveConversion(); + builder.Properties().HaveConversion(); } protected override void OnModelCreating(ModelBuilder b) @@ -107,6 +110,7 @@ public sealed class HaloContext(DbContextOptions options) : DbConte e.HasIndex(x => x.MovieId); e.HasIndex(x => x.EpisodeId); e.Property(x => x.Path).HasMaxLength(1024); + e.Property(x => x.Container).HasMaxLength(32); e.HasOne().WithMany().HasForeignKey(x => x.LibraryId) .OnDelete(DeleteBehavior.Cascade); }); @@ -131,6 +135,19 @@ public sealed class HaloContext(DbContextOptions options) : DbConte .OnDelete(DeleteBehavior.Cascade); }); + b.Entity(e => + { + e.HasKey(x => x.Id); + // Eine Spurnummer gibt es je Datei genau einmal. Ohne diese Regel + // legt ein zweimal untersuchter Film jede Spur doppelt an. + e.HasIndex(x => new { x.MediaFileId, x.Index }).IsUnique(); + e.Property(x => x.Codec).HasMaxLength(64); + e.Property(x => x.Language).HasMaxLength(16); + e.Property(x => x.Title).HasMaxLength(256); + e.Property(x => x.Kind).HasConversion().HasMaxLength(16); + e.HasOne().WithMany().HasForeignKey(x => x.MediaFileId).OnDelete(DeleteBehavior.Cascade); + }); + b.Entity(e => { e.HasKey(x => x.Id); diff --git a/server/src/Halo.Data/LibraryScanner.cs b/server/src/Halo.Data/LibraryScanner.cs index 045b703..563b9a4 100644 --- a/server/src/Halo.Data/LibraryScanner.cs +++ b/server/src/Halo.Data/LibraryScanner.cs @@ -11,6 +11,7 @@ public sealed record ScanReport( int Updated, int Removed, int Unrecognised, + int Probed, IReadOnlyList Problems); /// Gleicht ab, was auf der Platte liegt, mit dem, was in der Datenbank steht. @@ -29,7 +30,8 @@ public sealed record ScanReport( /// merken — jene Liste wüchse mit dem Bestand. /// /// -public sealed class LibraryScanner(HaloContext db, TimeProvider clock, ILogger log) +public sealed class LibraryScanner( + HaloContext db, IMediaProbe sonde, TimeProvider clock, ILogger log) { public async Task ScanAsync(Guid libraryId, CancellationToken ct = default) { @@ -59,7 +61,7 @@ public sealed class LibraryScanner(HaloContext db, TimeProvider clock, ILogger serien.Select(s => s.Id).Contains(f.SeriesId)).ToListAsync(ct) : []; - int neu = 0, geändert = 0, unerkannt = 0; + int neu = 0, geändert = 0, unerkannt = 0, untersucht = 0; foreach (var datei in ergebnis.Files) { @@ -88,19 +90,30 @@ public sealed class LibraryScanner(HaloContext db, TimeProvider clock, ILoggerLiest die Spuren einer Datei und schreibt sie fort. + /// + /// + /// Erst speichern, dann untersuchen. Ein neuer Eintrag hat seine + /// Kennung zwar schon, aber die Spuren verweisen darauf — und ohne die + /// Datei in der Tabelle liefe das in einen Fremdschlüsselfehler. Die + /// Reihenfolge ist deshalb keine Geschmacksfrage. + /// + /// Scheitert die Untersuchung, bleibt ProbedAt leer und der nächste + /// Lauf versucht es erneut. Eine Datei, die gerade noch geschrieben wird, + /// ist beim nächsten Mal vielleicht lesbar. + /// + private async Task UntersuchenAsync(MediaFile datei, DateTimeOffset laufZeit, CancellationToken ct) + { + var gelesen = await sonde.ProbeAsync(datei.Path, ct); + if (gelesen is null) return false; + + datei.Container = gelesen.Container; + datei.Duration = gelesen.Duration; + datei.BitRate = gelesen.BitRate; + datei.ProbedAt = laufZeit; + + await db.SaveChangesAsync(ct); + + // Die alten Spuren weg, bevor die neuen kommen: eine umkodierte Datei + // hat andere, und zwei Sätze nebeneinander ergäben Unsinn. + await db.MediaStreams.Where(s => s.MediaFileId == datei.Id).ExecuteDeleteAsync(ct); + + db.MediaStreams.AddRange(gelesen.Streams.Select(s => new MediaStream + { + MediaFileId = datei.Id, + Index = s.Index, + Kind = s.Kind, + Codec = s.Codec, + Language = s.Language, + Title = s.Title, + IsDefault = s.IsDefault, + IsForced = s.IsForced, + Channels = s.Channels, + Width = s.Width, + Height = s.Height, + })); + await db.SaveChangesAsync(ct); + return true; } /// Löscht Titel, zu denen keine Datei mehr gehört. diff --git a/server/src/Halo.Data/Migrations/20261003150124_Spuren.Designer.cs b/server/src/Halo.Data/Migrations/20261003150124_Spuren.Designer.cs new file mode 100644 index 0000000..b1339e8 --- /dev/null +++ b/server/src/Halo.Data/Migrations/20261003150124_Spuren.Designer.cs @@ -0,0 +1,484 @@ +// +using System; +using Halo.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Halo.Data.Migrations +{ + [DbContext(typeof(HaloContext))] + [Migration("20261003150124_Spuren")] + partial class Spuren + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.12"); + + modelBuilder.Entity("Halo.Core.Auth.AuthCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ApprovedByUserId") + .HasColumnType("TEXT"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("ConsumedAt") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("INTEGER"); + + b.Property("HandleFingerprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("HandleFingerprint") + .IsUnique(); + + b.ToTable("AuthCodes"); + }); + + modelBuilder.Entity("Halo.Core.Auth.DeviceToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("INTEGER"); + + b.Property("RevokedAt") + .HasColumnType("INTEGER"); + + b.Property("TokenFingerprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TokenFingerprint") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("DeviceTokens"); + }); + + modelBuilder.Entity("Halo.Core.Libraries.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Libraries"); + }); + + modelBuilder.Entity("Halo.Core.Library.Episode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedAt") + .HasColumnType("INTEGER"); + + b.Property("EpisodeNumber") + .HasColumnType("INTEGER"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("SeasonNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("StillImage") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId", "SeasonNumber", "EpisodeNumber") + .IsUnique(); + + b.ToTable("Episodes"); + }); + + modelBuilder.Entity("Halo.Core.Library.MediaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BitRate") + .HasColumnType("INTEGER"); + + b.Property("Container") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Duration") + .HasColumnType("INTEGER"); + + b.Property("EpisodeId") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("TEXT"); + + b.Property("ModifiedAt") + .HasColumnType("INTEGER"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("ProbedAt") + .HasColumnType("INTEGER"); + + b.Property("SeenAt") + .HasColumnType("INTEGER"); + + b.Property("SizeBytes") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId"); + + b.HasIndex("MovieId"); + + b.HasIndex("LibraryId", "Path") + .IsUnique(); + + b.ToTable("MediaFiles"); + }); + + modelBuilder.Entity("Halo.Core.Library.MediaStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Channels") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("IsForced") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Language") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("MediaFileId") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("MediaFileId", "Index") + .IsUnique(); + + b.ToTable("MediaStreams"); + }); + + modelBuilder.Entity("Halo.Core.Library.Movie", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedAt") + .HasColumnType("INTEGER"); + + b.Property("BackdropImage") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("ParsedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PosterImage") + .HasColumnType("TEXT"); + + b.Property("SortTitleValue") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TmdbId") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId", "SortTitleValue"); + + b.ToTable("Movies"); + }); + + modelBuilder.Entity("Halo.Core.Library.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedAt") + .HasColumnType("INTEGER"); + + b.Property("BackdropImage") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("ParsedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PosterImage") + .HasColumnType("TEXT"); + + b.Property("SortTitleValue") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TmdbId") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId", "SortTitleValue"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("Halo.Core.ServerSetting", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("Settings"); + }); + + modelBuilder.Entity("Halo.Core.Users.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("IsAdministrator") + .HasColumnType("INTEGER"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.HasKey("Id"); + + b.HasIndex("UserName") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Halo.Core.Auth.DeviceToken", b => + { + b.HasOne("Halo.Core.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Halo.Core.Library.Episode", b => + { + b.HasOne("Halo.Core.Library.Series", null) + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Halo.Core.Library.MediaFile", b => + { + b.HasOne("Halo.Core.Libraries.Library", null) + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Halo.Core.Library.MediaStream", b => + { + b.HasOne("Halo.Core.Library.MediaFile", null) + .WithMany() + .HasForeignKey("MediaFileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Halo.Core.Library.Movie", b => + { + b.HasOne("Halo.Core.Libraries.Library", null) + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Halo.Core.Library.Series", b => + { + b.HasOne("Halo.Core.Libraries.Library", null) + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/server/src/Halo.Data/Migrations/20261003150124_Spuren.cs b/server/src/Halo.Data/Migrations/20261003150124_Spuren.cs new file mode 100644 index 0000000..1bd01e5 --- /dev/null +++ b/server/src/Halo.Data/Migrations/20261003150124_Spuren.cs @@ -0,0 +1,97 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Halo.Data.Migrations +{ + /// + public partial class Spuren : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "BitRate", + table: "MediaFiles", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "Container", + table: "MediaFiles", + type: "TEXT", + maxLength: 32, + nullable: true); + + migrationBuilder.AddColumn( + name: "Duration", + table: "MediaFiles", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "ProbedAt", + table: "MediaFiles", + type: "INTEGER", + nullable: true); + + migrationBuilder.CreateTable( + name: "MediaStreams", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + MediaFileId = table.Column(type: "TEXT", nullable: false), + Index = table.Column(type: "INTEGER", nullable: false), + Kind = table.Column(type: "TEXT", maxLength: 16, nullable: false), + Codec = table.Column(type: "TEXT", maxLength: 64, nullable: true), + Language = table.Column(type: "TEXT", maxLength: 16, nullable: true), + Title = table.Column(type: "TEXT", maxLength: 256, nullable: true), + IsDefault = table.Column(type: "INTEGER", nullable: false), + IsForced = table.Column(type: "INTEGER", nullable: false), + Channels = table.Column(type: "INTEGER", nullable: true), + Width = table.Column(type: "INTEGER", nullable: true), + Height = table.Column(type: "INTEGER", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_MediaStreams", x => x.Id); + table.ForeignKey( + name: "FK_MediaStreams_MediaFiles_MediaFileId", + column: x => x.MediaFileId, + principalTable: "MediaFiles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_MediaStreams_MediaFileId_Index", + table: "MediaStreams", + columns: new[] { "MediaFileId", "Index" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "MediaStreams"); + + migrationBuilder.DropColumn( + name: "BitRate", + table: "MediaFiles"); + + migrationBuilder.DropColumn( + name: "Container", + table: "MediaFiles"); + + migrationBuilder.DropColumn( + name: "Duration", + table: "MediaFiles"); + + migrationBuilder.DropColumn( + name: "ProbedAt", + table: "MediaFiles"); + } + } +} diff --git a/server/src/Halo.Data/Migrations/HaloContextModelSnapshot.cs b/server/src/Halo.Data/Migrations/HaloContextModelSnapshot.cs index 1d4372b..9c8ba33 100644 --- a/server/src/Halo.Data/Migrations/HaloContextModelSnapshot.cs +++ b/server/src/Halo.Data/Migrations/HaloContextModelSnapshot.cs @@ -179,6 +179,16 @@ namespace Halo.Data.Migrations .ValueGeneratedOnAdd() .HasColumnType("TEXT"); + b.Property("BitRate") + .HasColumnType("INTEGER"); + + b.Property("Container") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Duration") + .HasColumnType("INTEGER"); + b.Property("EpisodeId") .HasColumnType("TEXT"); @@ -196,6 +206,9 @@ namespace Halo.Data.Migrations .HasMaxLength(1024) .HasColumnType("TEXT"); + b.Property("ProbedAt") + .HasColumnType("INTEGER"); + b.Property("SeenAt") .HasColumnType("INTEGER"); @@ -214,6 +227,58 @@ namespace Halo.Data.Migrations b.ToTable("MediaFiles"); }); + modelBuilder.Entity("Halo.Core.Library.MediaStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Channels") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("IsForced") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Language") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("MediaFileId") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("MediaFileId", "Index") + .IsUnique(); + + b.ToTable("MediaStreams"); + }); + modelBuilder.Entity("Halo.Core.Library.Movie", b => { b.Property("Id") @@ -384,6 +449,15 @@ namespace Halo.Data.Migrations .IsRequired(); }); + modelBuilder.Entity("Halo.Core.Library.MediaStream", b => + { + b.HasOne("Halo.Core.Library.MediaFile", null) + .WithMany() + .HasForeignKey("MediaFileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("Halo.Core.Library.Movie", b => { b.HasOne("Halo.Core.Libraries.Library", null) diff --git a/server/src/Halo.Media/FfmpegOptions.cs b/server/src/Halo.Media/FfmpegOptions.cs new file mode 100644 index 0000000..8021278 --- /dev/null +++ b/server/src/Halo.Media/FfmpegOptions.cs @@ -0,0 +1,25 @@ +namespace Halo.Media; + +/// Wo ffmpeg liegt und wie lange es brauchen darf. +/// +/// +/// Die Pfade sind einstellbar, weil sie nicht überall gleich sind: im +/// Container liegt ffmpeg unter /usr/bin, auf einem Mac mit Homebrew +/// unter /opt/homebrew/bin, und manche bringen ihren eigenen Bau mit. +/// Ein fester Pfad hier wäre eine Annahme über ein fremdes System. +/// +public sealed record FfmpegOptions(string Ffmpeg = "ffmpeg", string Ffprobe = "ffprobe") +{ + /// Nach dieser Zeit gilt eine Datei als nicht lesbar. + /// + /// + /// Eine beschädigte oder halb geschriebene Datei bringt ffprobe dazu, + /// endlos zu suchen. Ohne Grenze bliebe der Scan daran hängen — und zwar + /// an *einer* Datei, während zehntausend andere warten. + /// + public TimeSpan ProbeTimeout { get; init; } = TimeSpan.FromSeconds(30); + + public static FfmpegOptions FromEnvironment() => new( + Environment.GetEnvironmentVariable("HALO_FFMPEG") is { Length: > 0 } f ? f : "ffmpeg", + Environment.GetEnvironmentVariable("HALO_FFPROBE") is { Length: > 0 } p ? p : "ffprobe"); +} diff --git a/server/src/Halo.Media/FfmpegRemuxer.cs b/server/src/Halo.Media/FfmpegRemuxer.cs new file mode 100644 index 0000000..6bd5cb1 --- /dev/null +++ b/server/src/Halo.Media/FfmpegRemuxer.cs @@ -0,0 +1,133 @@ +using System.Diagnostics; +using System.Globalization; +using Microsoft.Extensions.Logging; + +namespace Halo.Media; + +public sealed record RemuxOutcome(bool Success, string? Error); + +/// Packt eine Datei in eine MP4 um — mit allen Spuren. +/// +/// +/// +/// Das ist der Grund, warum es diesen Server gibt. Jellyfins +/// Transcode-Endpunkt bildet je Sitzung eine Tonspur ab; was offline landet, +/// ist einspurig. Hier wandern alle mit, weil jede einzeln benannt wird und +/// nichts die Auswahl auf eine beschränkt. +/// +/// +/// Keine Zeitgrenze. Anders als beim Untersuchen: ein Film mit vierzig +/// Gigabyte braucht Minuten, und ihn nach dreissig Sekunden abzubrechen wäre +/// kein Schutz, sondern ein Fehler. Abgebrochen wird über das +/// Abbruchzeichen — also wenn jemand es will oder der Server herunterfährt. +/// +/// +public sealed class FfmpegRemuxer(FfmpegOptions optionen, ILogger log) +{ + public async Task RunAsync( + RemuxRequest anfrage, + TimeSpan? gesamtdauer = null, + IProgress? fortschritt = null, + CancellationToken ct = default) + { + var argumente = RemuxArguments.For(anfrage); + + var start = new ProcessStartInfo(optionen.Ffmpeg) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + // Fortschritt auf die Standardausgabe, damit Fehlermeldungen für sich + // bleiben. Vermischt wären beide schwer auseinanderzuhalten. + if (fortschritt is not null && gesamtdauer is { TotalSeconds: > 0 }) + { + start.ArgumentList.Add("-progress"); + start.ArgumentList.Add("pipe:1"); + start.ArgumentList.Add("-nostats"); + } + foreach (var a in argumente) start.ArgumentList.Add(a); + + log.LogInformation("Packe um: {Quelle} → {Ziel}", anfrage.SourcePath, anfrage.TargetPath); + + try + { + using var prozess = Process.Start(start) + ?? throw new InvalidOperationException($"{optionen.Ffmpeg} liess sich nicht starten."); + + var lesenFortschritt = FortschrittLesen(prozess, gesamtdauer, fortschritt, ct); + var lesenFehler = prozess.StandardError.ReadToEndAsync(ct); + + try + { + await prozess.WaitForExitAsync(ct); + } + catch (OperationCanceledException) + { + // **Sonst läuft ffmpeg weiter.** Das Abbruchzeichen beendet + // nur das Warten, nicht den Prozess — und der schriebe dann + // unbeaufsichtigt weiter auf die Platte. + try { prozess.Kill(entireProcessTree: true); } catch (InvalidOperationException) { } + Aufräumen(anfrage.TargetPath); + throw; + } + + await lesenFortschritt; + var fehlertext = (await lesenFehler).Trim(); + + if (prozess.ExitCode != 0) + { + Aufräumen(anfrage.TargetPath); + log.LogWarning("ffmpeg gab {Code} zurück: {Fehler}", prozess.ExitCode, fehlertext); + return new RemuxOutcome(false, fehlertext.Length > 0 ? fehlertext : $"ffmpeg endete mit {prozess.ExitCode}."); + } + + fortschritt?.Report(1.0); + return new RemuxOutcome(true, null); + } + catch (OperationCanceledException) { throw; } + catch (Exception fehler) + { + Aufräumen(anfrage.TargetPath); + log.LogError(fehler, "Das Umpacken von {Quelle} ist gescheitert.", anfrage.SourcePath); + return new RemuxOutcome(false, fehler.Message); + } + } + + /// Liest die Fortschrittszeilen von ffmpeg mit. + /// + /// + /// ffmpeg schreibt dort Zeilen der Form out_time_us=12345678. + /// Gelesen wird laufend — nicht am Ende: ein Fortschritt, der erst + /// vorliegt, wenn alles fertig ist, hilft niemandem. + /// + private static async Task FortschrittLesen( + Process prozess, TimeSpan? gesamt, IProgress? fortschritt, CancellationToken ct) + { + while (await prozess.StandardOutput.ReadLineAsync(ct) is { } zeile) + { + if (fortschritt is null || gesamt is not { TotalMicroseconds: > 0 }) continue; + if (!zeile.StartsWith("out_time_us=", StringComparison.Ordinal)) continue; + + if (double.TryParse(zeile.AsSpan("out_time_us=".Length), NumberStyles.Float, + CultureInfo.InvariantCulture, out var mikrosekunden) && mikrosekunden >= 0) + { + fortschritt.Report(Math.Clamp(mikrosekunden / gesamt.Value.TotalMicroseconds, 0, 0.999)); + } + } + } + + /// Eine halbe Datei ist schlimmer als keine. + /// + /// + /// Bliebe sie liegen, hielte der nächste Aufruf sie für fertig und gäbe + /// einen abgeschnittenen Film heraus — der beim Abspielen mittendrin + /// abbricht. Genau dieser Fehler hat beim Herunterladen schon einmal + /// Stunden gekostet. + /// + private void Aufräumen(string ziel) + { + try { if (File.Exists(ziel)) File.Delete(ziel); } + catch (IOException fehler) { log.LogWarning(fehler, "Reste von {Ziel} liessen sich nicht wegräumen.", ziel); } + } +} diff --git a/server/src/Halo.Media/FfprobeRunner.cs b/server/src/Halo.Media/FfprobeRunner.cs new file mode 100644 index 0000000..1be0039 --- /dev/null +++ b/server/src/Halo.Media/FfprobeRunner.cs @@ -0,0 +1,201 @@ +using System.Diagnostics; +using System.Globalization; +using System.Text.Json; +using Halo.Core.Library; +using Microsoft.Extensions.Logging; + +namespace Halo.Media; + +/// Liest die technischen Angaben einer Datei mit ffprobe. +/// +/// +/// +/// Als eigener Prozess, nicht über Bindings. Das hält die Lizenzen +/// getrennt — ffmpeg steht unter GPL, und ihn einzubinden hiesse, diese Frage +/// an jeder Stelle mitzuführen. Nebenbei kann ein abgestürztes ffprobe so den +/// Server nicht mitnehmen. +/// +/// +/// Die Argumente gehen als Liste, nicht als Zeile. Ein Dateiname wie +/// Der "echte" Film.mkv oder einer mit einem Apostroph zerlegte eine +/// zusammengesetzte Kommandozeile — und was dann ausgeführt würde, bestimmte +/// der Dateiname. Mit gibt es +/// diese Frage gar nicht erst. +/// +/// +public sealed class FfprobeRunner(FfmpegOptions optionen, ILogger log) : IMediaProbe +{ + public async Task ProbeAsync(string pfad, CancellationToken ct = default) + { + var start = new ProcessStartInfo(optionen.Ffprobe) + { + ArgumentList = { "-v", "quiet", "-print_format", "json", "-show_streams", "-show_format", "-i", pfad }, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + + using var frist = CancellationTokenSource.CreateLinkedTokenSource(ct); + frist.CancelAfter(optionen.ProbeTimeout); + + string ausgabe; + try + { + using var prozess = Process.Start(start) + ?? throw new InvalidOperationException($"{optionen.Ffprobe} liess sich nicht starten."); + + // **Beides gleichzeitig lesen.** Wer erst wartet und dann liest, + // bleibt stehen, sobald ffprobe genug schreibt, um den Puffer der + // Weiterleitung zu füllen — und das tut es bei einer Datei mit + // vielen Spuren. + var lesenAus = prozess.StandardOutput.ReadToEndAsync(frist.Token); + var lesenFehler = prozess.StandardError.ReadToEndAsync(frist.Token); + await prozess.WaitForExitAsync(frist.Token); + ausgabe = await lesenAus; + var fehlertext = await lesenFehler; + + if (prozess.ExitCode != 0) + { + log.LogWarning("ffprobe gab {Code} für {Pfad} zurück: {Fehler}", + prozess.ExitCode, pfad, fehlertext.Trim()); + return null; + } + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (OperationCanceledException) + { + log.LogWarning("ffprobe brauchte für {Pfad} länger als {Frist} und wurde abgebrochen.", + pfad, optionen.ProbeTimeout); + return null; + } + catch (Exception fehler) + { + log.LogWarning(fehler, "ffprobe liess sich für {Pfad} nicht ausführen.", pfad); + return null; + } + + return Lesen(ausgabe, pfad); + } + + private ProbeResult? Lesen(string json, string pfad) + { + try + { + using var urkunde = JsonDocument.Parse(json); + var wurzel = urkunde.RootElement; + + string? schachtel = null; + TimeSpan? dauer = null; + long? rate = null; + + if (wurzel.TryGetProperty("format", out var format)) + { + schachtel = Schachtel(Text(format, "format_name")); + if (double.TryParse(Text(format, "duration"), NumberStyles.Float, CultureInfo.InvariantCulture, out var s) + && s > 0) dauer = TimeSpan.FromSeconds(s); + if (long.TryParse(Text(format, "bit_rate"), out var b) && b > 0) rate = b; + } + + var spuren = new List(); + if (wurzel.TryGetProperty("streams", out var ströme) && ströme.ValueKind == JsonValueKind.Array) + { + foreach (var strom in ströme.EnumerateArray()) + { + var art = Art(Text(strom, "codec_type")); + if (art is null) continue; // Anhänge, Daten, Bilder im Dateikopf + + strom.TryGetProperty("disposition", out var lage); + strom.TryGetProperty("tags", out var kennzeichen); + + spuren.Add(new ProbedStream( + Index: strom.TryGetProperty("index", out var i) && i.TryGetInt32(out var n) ? n : spuren.Count, + Kind: art.Value, + Codec: Text(strom, "codec_name"), + Language: Sprache(Text(kennzeichen, "language")), + Title: Spurname(kennzeichen), + IsDefault: Zahl(lage, "default") == 1, + IsForced: Zahl(lage, "forced") == 1, + Channels: strom.TryGetProperty("channels", out var k) && k.TryGetInt32(out var kz) ? kz : null, + Width: strom.TryGetProperty("width", out var w) && w.TryGetInt32(out var wz) ? wz : null, + Height: strom.TryGetProperty("height", out var h) && h.TryGetInt32(out var hz) ? hz : null)); + } + } + + return new ProbeResult(schachtel, dauer, rate, spuren); + } + catch (JsonException fehler) + { + log.LogWarning(fehler, "Die Antwort von ffprobe für {Pfad} war nicht lesbar.", pfad); + return null; + } + } + + /// ffprobe nennt mehrere Namen auf einmal — hier zählt der erste. + /// + /// + /// Eine MP4 meldet sich als mov,mp4,m4a,3gp,3g2,mj2, weil ffmpeg + /// diese Familie mit einem Leser bedient. Für die Frage, ob AVPlayer die + /// Datei öffnet, ist das die MP4-Familie — also wird sie dazu + /// vereinheitlicht, statt sechs Namen weiterzureichen. + /// + private static string? Schachtel(string? gemeldet) + { + if (string.IsNullOrWhiteSpace(gemeldet)) return null; + var erster = gemeldet.Split(',')[0].Trim().ToLowerInvariant(); + return erster switch + { + "mov" => "mp4", + "matroska" => "mkv", + "mpegts" => "ts", + _ => erster, + }; + } + + /// Der Name der Spur — das Feld heisst je nach Schachtel anders. + /// + /// + /// Matroska nennt es title, MP4 nennt es name. Beim + /// Umpacken schreibt ffmpeg das eine ins andere um, und wer nur + /// title liest, hält jede umgepackte Datei für namenlos. Genau das + /// ist beim ersten Versuch passiert: nach dem Umpacken waren „Deutsch" + /// und „Audiodeskription" beide null — und damit zwei deutsche + /// Tonspuren nicht mehr auseinanderzuhalten. + /// + /// handler_name steht ausdrücklich nicht dabei: dort schreibt + /// ffmpeg „SoundHandler" hinein, und das ist kein Name, sondern eine + /// Formalie. + /// + private static string? Spurname(JsonElement kennzeichen) => + Text(kennzeichen, "title") ?? Text(kennzeichen, "name"); + + /// „und" heisst unbekannt und ist dasselbe wie keine Angabe. + private static string? Sprache(string? roh) => + string.IsNullOrWhiteSpace(roh) || roh.Equals("und", StringComparison.OrdinalIgnoreCase) + ? null + : roh.Trim().ToLowerInvariant(); + + private static MediaStreamKind? Art(string? typ) => typ switch + { + "video" => MediaStreamKind.Video, + "audio" => MediaStreamKind.Audio, + "subtitle" => MediaStreamKind.Subtitle, + _ => null, + }; + + private static string? Text(JsonElement eltern, string name) => + eltern.ValueKind == JsonValueKind.Object + && eltern.TryGetProperty(name, out var wert) + && wert.ValueKind == JsonValueKind.String + ? wert.GetString() + : null; + + private static int? Zahl(JsonElement eltern, string name) => + eltern.ValueKind == JsonValueKind.Object + && eltern.TryGetProperty(name, out var wert) + && wert.TryGetInt32(out var zahl) + ? zahl + : null; +} diff --git a/server/src/Halo.Media/Halo.Media.csproj b/server/src/Halo.Media/Halo.Media.csproj new file mode 100644 index 0000000..14ec534 --- /dev/null +++ b/server/src/Halo.Media/Halo.Media.csproj @@ -0,0 +1,17 @@ + + + + + + + + + + + + net10.0 + enable + enable + + + diff --git a/server/src/Halo.Media/RemuxArguments.cs b/server/src/Halo.Media/RemuxArguments.cs new file mode 100644 index 0000000..15800ac --- /dev/null +++ b/server/src/Halo.Media/RemuxArguments.cs @@ -0,0 +1,103 @@ +using Halo.Core.Delivery; +using Halo.Core.Library; + +namespace Halo.Media; + +public sealed record RemuxRequest( + string SourcePath, + string TargetPath, + IReadOnlyList Streams, + DeliveryPlan Plan); + +/// Baut die ffmpeg-Zeile fürs Umpacken. +/// +/// +/// +/// Eine reine Funktion, damit sie geprüft werden kann, ohne etwas +/// auszuführen. Hier liegt die Erfahrung dieser Woche: eine falsche +/// Kombination von Parametern kostete uns Tage, weil sie nur gegen einen +/// echten Server zu entdecken war. Wo sich die Argumente allein betrachten +/// lassen, geht das in Millisekunden. +/// +/// +/// Jede Spur wird einzeln benannt, statt mit -map 0 alles zu +/// nehmen und Unerwünschtes wieder abzuziehen. Beides ginge; die ausdrückliche +/// Liste sagt aber hinterher, was tatsächlich gemeint war — und eine +/// Prüfung kann sie lesen. +/// +/// +public static class RemuxArguments +{ + /// Was eine Tonspur je Kanal bekommt, wenn sie neu gerechnet wird. + private const int BitrateJeKanal = 96_000; + private const int HöchsteBitrate = 640_000; + + public static List For(RemuxRequest anfrage) + { + var weggelassen = anfrage.Plan.SubtitlesDropped.ToHashSet(); + var neuZuRechnen = anfrage.Plan.AudioToEncode.ToHashSet(); + + // Anhänge und Datenströme fliegen schon hier heraus: MP4 hat für + // Schriftarten und Kapitelbilder keinen Platz, und ffmpeg bräche mit + // einer Fehlermeldung ab, die nach etwas ganz anderem aussieht. + var genommen = anfrage.Streams + .Where(s => s.Kind is MediaStreamKind.Video or MediaStreamKind.Audio or MediaStreamKind.Subtitle) + .Where(s => !weggelassen.Contains(s.Index)) + .OrderBy(s => s.Index) + .ToList(); + + var argumente = new List + { + "-y", + "-loglevel", "error", + "-i", anfrage.SourcePath, + }; + + foreach (var spur in genommen) + { + argumente.Add("-map"); + argumente.Add($"0:{spur.Index}"); + } + + // Alles kopieren, und nur dort abweichen, wo es nicht anders geht. + argumente.Add("-c"); + argumente.Add("copy"); + + // MP4 kennt nur tx3g. Textuntertitel werden umgeschrieben — das ist + // keine Neukodierung des Inhalts, nur eine andere Ablageform. + if (genommen.Any(s => s.Kind == MediaStreamKind.Subtitle)) + { + argumente.Add("-c:s"); + argumente.Add("mov_text"); + } + + // **Die Nummer je Art, nicht die der Datei.** `-c:a:1` meint die + // zweite Tonspur der Ausgabe; wer hier die Dateinummer einsetzt, + // trifft eine andere Spur oder gar keine. Genau diese Verwechslung + // steht als Warnung an `MediaStream.Index`. + var tonspuren = genommen.Where(s => s.Kind == MediaStreamKind.Audio).ToList(); + for (var stelle = 0; stelle < tonspuren.Count; stelle++) + { + var spur = tonspuren[stelle]; + if (!neuZuRechnen.Contains(spur.Index)) continue; + + argumente.Add($"-c:a:{stelle}"); + argumente.Add("aac"); + argumente.Add($"-b:a:{stelle}"); + argumente.Add(Bitrate(spur.Channels).ToString()); + } + + // Das Inhaltsverzeichnis nach vorn. Ohne das muss ein Abspieler erst + // ans Dateiende springen, bevor er irgendetwas zeigen kann — bei einer + // heruntergeladenen Datei ist das der Unterschied zwischen „läuft + // sofort" und „denkt zehn Sekunden nach". + argumente.Add("-movflags"); + argumente.Add("+faststart"); + + argumente.Add(anfrage.TargetPath); + return argumente; + } + + private static int Bitrate(int? kanäle) => + Math.Min(HöchsteBitrate, Math.Max(1, kanäle ?? 2) * BitrateJeKanal); +} diff --git a/server/tests/Halo.Api.Tests/DeliveryTests.cs b/server/tests/Halo.Api.Tests/DeliveryTests.cs new file mode 100644 index 0000000..4bcfd5a --- /dev/null +++ b/server/tests/Halo.Api.Tests/DeliveryTests.cs @@ -0,0 +1,192 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using Halo.Api.Endpoints; + +namespace Halo.Api.Tests; + +/// Der Weg vom Scan bis zur heruntergeladenen Datei. +public class DeliveryTests +{ + private string _bestand = ""; + + 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(), "halo-liefern-" + Guid.CreateVersion7().ToString("N"))).FullName; + } + + [TearDown] + public void Aufraeumen() + { + try { if (_bestand.Length > 0) Directory.Delete(_bestand, recursive: true); } catch (IOException) { } + } + + /// Eine Datei mit zwei Tonspuren, in der gewünschten Schachtel. + private string Film(string name, string endung) + { + var ziel = Path.Combine(_bestand, $"{name}.{endung}"); + var start = new ProcessStartInfo(Werkzeug("ffmpeg")!) { RedirectStandardError = true, UseShellExecute = false }; + foreach (var a in new[] + { + "-y", "-loglevel", "error", + "-f", "lavfi", "-i", "testsrc=duration=1:size=160x120:rate=10", + "-f", "lavfi", "-i", "sine=frequency=440:duration=1", + "-f", "lavfi", "-i", "sine=frequency=880:duration=1", + "-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=deu", "-metadata:s:a:1", "title=Audiodeskription", + "-c:v", "libx264", "-preset", "ultrafast", "-c:a", "aac", 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 HaloServer(); + 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 < 150; 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 HaloServer? _server; + + [TearDown] + public void ServerSchliessen() { _server?.Dispose(); _server = null; } + + /// Der Scan trägt die Spuren ein — darunter die beiden Namen. + [Test] + public async Task Die_Spuren_stehen_in_der_Mediathek() + { + Film("Zwei Spuren (2020)", "mkv"); + var (_, datei) = await Eingelesen(); + + Assert.That(datei.Container, Is.EqualTo("mkv")); + Assert.That(datei.DurationSeconds, Is.EqualTo(1).Within(0.5)); + + var ton = datei.Streams.Where(s => s.Kind == "Audio").ToList(); + Assert.That(ton, Has.Count.EqualTo(2)); + Assert.That(ton.Select(t => t.Title), Is.EqualTo(new[] { "Deutsch", "Audiodeskription" })); + } + + /// Der volle Pfad des Servers geht niemanden etwas an. + [Test] + public async Task Nur_der_Dateiname_kommt_heraus_nicht_der_Pfad() + { + Film("Pfadprobe (2020)", "mkv"); + var (_, datei) = await Eingelesen(); + + Assert.That(datei.FileName, Is.EqualTo("Pfadprobe (2020).mkv")); + Assert.That(datei.FileName, Does.Not.Contain("/")); + } + + [Test] + public async Task Eine_passende_MP4_geht_unveraendert_hinaus() + { + Film("Passend (2020)", "mp4"); + var (client, datei) = await Eingelesen(); + + var auskunft = await client.GetFromJsonAsync($"/api/v1/files/{datei.Id}/delivery"); + Assert.That(auskunft!.Method, Is.EqualTo("directFile")); + Assert.That(auskunft.PackageState, Is.EqualTo("notNeeded")); + + var antwort = await client.GetAsync($"/api/v1/files/{datei.Id}/download"); + Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + Assert.That(antwort.Content.Headers.ContentLength, Is.GreaterThan(0), + "Ohne Längenangabe gäbe es keinen Fortschrittsbalken"); + } + + /// Eine Matroska wird umgepackt — und der Client erfährt es vorher. + [Test] + public async Task Eine_Matroska_wird_angekuendigt_und_dann_umgepackt() + { + Film("Umzupacken (2020)", "mkv"); + var (client, datei) = await Eingelesen(); + + var auskunft = await client.GetFromJsonAsync($"/api/v1/files/{datei.Id}/delivery"); + Assert.That(auskunft!.Method, Is.EqualTo("remux")); + Assert.That(auskunft.Reasons, Does.Contain("containerUnsupported")); + Assert.That(auskunft.AudioToEncode, Is.Empty, "Beide Tonspuren passen und werden nur umgepackt"); + + // Der erste Abruf stösst an und wartet nicht. + var erster = await client.GetAsync($"/api/v1/files/{datei.Id}/download"); + Assert.That(erster.StatusCode, Is.EqualTo(HttpStatusCode.Accepted)); + + HttpResponseMessage? fertig = null; + for (var i = 0; i < 150; i++) + { + fertig = await client.GetAsync($"/api/v1/files/{datei.Id}/download"); + if (fertig.StatusCode != HttpStatusCode.Accepted) break; + await Task.Delay(100); + } + + Assert.That(fertig!.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + Assert.That(fertig.Content.Headers.ContentLength, Is.GreaterThan(0)); + Assert.That(fertig.Content.Headers.ContentDisposition?.FileNameStar ?? "", + Does.EndWith(".mp4"), "Heruntergeladen wird eine MP4, nicht eine MKV"); + } + + /// Fortsetzen nach einem Abbruch — die Forderung aus dem Fehlerbericht. + [Test] + public async Task Die_Auslieferung_versteht_Bereichsanfragen() + { + Film("Bereich (2020)", "mp4"); + var (client, datei) = await Eingelesen(); + + var anfrage = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/files/{datei.Id}/download"); + anfrage.Headers.Range = new RangeHeaderValue(100, 199); + var antwort = await client.SendAsync(anfrage); + + Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.PartialContent)); + Assert.That((await antwort.Content.ReadAsByteArrayAsync()), Has.Length.EqualTo(100)); + } + + [Test] + public async Task Eine_Datei_die_es_nicht_gibt_ergibt_404() + { + Film("Egal (2020)", "mp4"); + var (client, _) = await Eingelesen(); + + var antwort = await client.GetAsync($"/api/v1/files/{Guid.CreateVersion7()}/download"); + Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); + } + + [Test] + public async Task Ohne_Anmeldung_gibt_es_nichts() + { + Film("Geschuetzt (2020)", "mp4"); + var (_, datei) = await Eingelesen(); + + var antwort = await _server!.Client.GetAsync($"/api/v1/files/{datei.Id}/download"); + Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); + } +} diff --git a/server/tests/Halo.Core.Tests/DeliveryDeciderTests.cs b/server/tests/Halo.Core.Tests/DeliveryDeciderTests.cs new file mode 100644 index 0000000..7e60c20 --- /dev/null +++ b/server/tests/Halo.Core.Tests/DeliveryDeciderTests.cs @@ -0,0 +1,133 @@ +using Halo.Core.Delivery; +using Halo.Core.Library; + +namespace Halo.Core.Tests; + +public class DeliveryDeciderTests +{ + private static ProbedStream Bild(string codec = "h264") => + new(0, MediaStreamKind.Video, codec, null, null, true, false, null, 1920, 1080); + + private static ProbedStream Ton(int index, string codec = "aac", string? sprache = "deu", string? name = null) => + new(index, MediaStreamKind.Audio, codec, sprache, name, index == 1, false, 6, null, null); + + private static ProbedStream Text(int index, string codec = "subrip") => + new(index, MediaStreamKind.Subtitle, codec, "deu", null, false, false, null, null, null); + + [Test] + public void Eine_passende_MP4_geht_unveraendert_hinaus() + { + var plan = DeliveryDecider.ForDownload("mp4", [Bild(), Ton(1)]); + + Assert.That(plan.Method, Is.EqualTo(DeliveryMethod.DirectFile)); + Assert.That(plan.NeedsWork, Is.False); + Assert.That(plan.Reasons, Is.EqualTo(new[] { DeliveryReason.NativelySupported })); + } + + [Test] + public void Eine_Matroska_muss_umgepackt_werden_auch_wenn_alles_darin_passt() + { + var plan = DeliveryDecider.ForDownload("mkv", [Bild(), Ton(1)]); + + Assert.That(plan.Method, Is.EqualTo(DeliveryMethod.Remux)); + Assert.That(plan.Reasons, Does.Contain(DeliveryReason.ContainerUnsupported)); + // Das Bild bleibt unberührt, und kein Ton muss gerechnet werden. + Assert.That(plan.AudioToEncode, Is.Empty); + } + + /// Der Fall, um den es im ganzen Projekt geht. + /// + /// + /// Zwei Tonspuren, und beide kommen mit. Der Plan hat gar kein Feld + /// „welche Tonspur" — und das ist der Unterschied zu Jellyfin, dessen + /// Transcode-Endpunkt je Sitzung genau eine abbildet. Wer hier eine + /// auswählen wollte, fände keine Stelle dafür. + /// + [Test] + public void Alle_Tonspuren_bleiben_erhalten() + { + var plan = DeliveryDecider.ForDownload("mkv", + [Bild(), Ton(1, name: "Deutsch"), Ton(2, name: "Audiodeskription"), Ton(3, sprache: "eng")]); + + Assert.That(plan.Method, Is.EqualTo(DeliveryMethod.Remux)); + Assert.That(plan.AudioToEncode, Is.Empty, "Alle drei passen und werden nur umgepackt"); + Assert.That(plan.SubtitlesDropped, Is.Empty); + } + + /// DTS kennt AVPlayer nicht — nur diese eine Spur wird gerechnet. + [Test] + public void Nur_untaugliche_Tonspuren_werden_neu_gerechnet() + { + var plan = DeliveryDecider.ForDownload("mkv", [Bild(), Ton(1, "aac"), Ton(2, "dts"), Ton(3, "truehd")]); + + Assert.That(plan.AudioToEncode, Is.EqualTo(new[] { 2, 3 })); + Assert.That(plan.Reasons, Does.Contain(DeliveryReason.AudioCodecUnsupported)); + } + + [Test] + public void Textuntertitel_werden_fuer_MP4_umgeschrieben() + { + var plan = DeliveryDecider.ForDownload("mkv", [Bild(), Ton(1), Text(2, "subrip")]); + + Assert.That(plan.Reasons, Does.Contain(DeliveryReason.SubtitlesNeedConversion)); + Assert.That(plan.SubtitlesDropped, Is.Empty); + } + + /// Betrifft fast jeden Blu-ray-Rip. + /// + /// + /// PGS liegt als Bild vor, und in eine MP4 passt das nicht. Diese Spuren + /// fallen weg — und das muss gesagt werden, statt stillschweigend zu + /// geschehen: sonst sucht jemand nach Untertiteln, die der Server bewusst + /// weggelassen hat. + /// + [Test] + public void Bilduntertitel_fallen_weg_und_das_steht_im_Plan() + { + var plan = DeliveryDecider.ForDownload("mkv", + [Bild(), Ton(1), Text(2, "hdmv_pgs_subtitle"), Text(3, "subrip")]); + + Assert.That(plan.SubtitlesDropped, Is.EqualTo(new[] { 2 })); + Assert.That(plan.Reasons, Does.Contain(DeliveryReason.ImageSubtitlesDropped)); + } + + [Test] + public void Ein_untaugliches_Bild_verlangt_ebenfalls_Umpacken() + { + var plan = DeliveryDecider.ForDownload("mkv", [Bild("vp9"), Ton(1)]); + Assert.That(plan.Reasons, Does.Contain(DeliveryReason.VideoCodecUnsupported)); + } + + /// Was die Datei nicht nennt, gilt als passend. + /// + /// + /// Vorsichtshalber umzupacken hiesse, aus einer tadellosen MP4 eine neue + /// Datei zu machen — für eine Angabe, die schlicht fehlt. + /// + [Test] + public void Fehlende_Angaben_loesen_kein_Umpacken_aus() + { + var plan = DeliveryDecider.ForDownload(null, + [Bild(null!) with { Codec = null }, Ton(1) with { Codec = null }]); + + Assert.That(plan.Method, Is.EqualTo(DeliveryMethod.DirectFile)); + } + + [Test] + public void Jeder_Grund_steht_nur_einmal_da() + { + var plan = DeliveryDecider.ForDownload("mkv", [Bild(), Ton(1, "dts"), Ton(2, "truehd"), Ton(3, "vorbis")]); + + Assert.That(plan.Reasons.Count(g => g == DeliveryReason.AudioCodecUnsupported), Is.EqualTo(1)); + } + + [Test] + public void Ein_eigenes_Geraeteprofil_sticht_die_Voreinstellung() + { + var alleskoenner = new DeviceProfile( + new HashSet { "mkv" }, new HashSet { "h264" }, new HashSet { "dts" }); + + var plan = DeliveryDecider.ForDownload("mkv", [Bild(), Ton(1, "dts")], alleskoenner); + Assert.That(plan.Method, Is.EqualTo(DeliveryMethod.DirectFile)); + } +} diff --git a/server/tests/Halo.Media.Tests/FfprobeRunnerTests.cs b/server/tests/Halo.Media.Tests/FfprobeRunnerTests.cs new file mode 100644 index 0000000..f1f9218 --- /dev/null +++ b/server/tests/Halo.Media.Tests/FfprobeRunnerTests.cs @@ -0,0 +1,150 @@ +using Halo.Core.Library; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Halo.Media.Tests; + +public class FfprobeRunnerTests +{ + private string _ordner = ""; + private FfprobeRunner _ffprobe = null!; + + [SetUp] + public void Anlegen() + { + if (!Testdateien.FfmpegVorhanden) Assert.Ignore("ffmpeg ist auf diesem Rechner nicht installiert."); + _ordner = Directory.CreateDirectory( + Path.Combine(Path.GetTempPath(), "halo-probe-" + Guid.CreateVersion7().ToString("N"))).FullName; + _ffprobe = new FfprobeRunner( + new FfmpegOptions(Testdateien.Suchen("ffmpeg")!, Testdateien.Suchen("ffprobe")!), + NullLogger.Instance); + } + + [TearDown] + public void Aufraeumen() + { + try { if (_ordner.Length > 0) Directory.Delete(_ordner, recursive: true); } catch (IOException) { } + } + + /// Die Frage, um die es im ganzen Projekt geht. + /// + /// + /// Zwei Tonspuren, beide deutsch, eine davon Audiodeskription. Genau + /// diese Datei hat die Sache ausgelöst: die Sprachkennung ist bei beiden + /// „deu", und nur der Name unterscheidet sie. Wer hier nur die Sprache + /// liest, kann die Frage „welche willst du?" gar nicht stellen. + /// + [Test] + public async Task Zwei_Tonspuren_werden_mit_Sprache_und_Namen_gelesen() + { + var datei = Path.Combine(_ordner, "zwei-tonspuren.mkv"); + Testdateien.MitZweiTonspuren(datei); + + var ergebnis = await _ffprobe.ProbeAsync(datei); + + Assert.That(ergebnis, Is.Not.Null); + var ton = ergebnis!.Streams.Where(s => s.Kind == MediaStreamKind.Audio).ToList(); + + Assert.That(ton, Has.Count.EqualTo(2)); + Assert.That(ton.Select(t => t.Language), Is.EqualTo(new[] { "deu", "deu" })); + Assert.That(ton.Select(t => t.Title), Is.EqualTo(new[] { "Deutsch", "Audiodeskription" })); + Assert.That(ton[0].Codec, Is.EqualTo("aac")); + } + + [Test] + public async Task Bild_Ton_und_Untertitel_werden_auseinandergehalten() + { + var datei = Path.Combine(_ordner, "alles.mkv"); + Testdateien.MitZweiTonspuren(datei); + + var ergebnis = await _ffprobe.ProbeAsync(datei); + + Assert.That(ergebnis!.Streams.Count(s => s.Kind == MediaStreamKind.Video), Is.EqualTo(1)); + Assert.That(ergebnis.Streams.Count(s => s.Kind == MediaStreamKind.Audio), Is.EqualTo(2)); + Assert.That(ergebnis.Streams.Count(s => s.Kind == MediaStreamKind.Subtitle), Is.EqualTo(1)); + } + + /// Die Nummern müssen die von ffmpeg sein, nicht eine eigene Zählung. + /// + /// + /// An ihnen hängt später das Umpacken: -map 0:2 spricht die dritte + /// Spur an. Eine eigene Zählung je Art wäre dort eine Falle. + /// + [Test] + public async Task Die_Spurnummern_sind_die_von_ffmpeg() + { + var datei = Path.Combine(_ordner, "nummern.mkv"); + Testdateien.MitZweiTonspuren(datei); + + var ergebnis = await _ffprobe.ProbeAsync(datei); + Assert.That(ergebnis!.Streams.Select(s => s.Index), Is.EqualTo(new[] { 0, 1, 2, 3 })); + } + + [Test] + public async Task Schachtel_Dauer_und_Bildgroesse_kommen_mit() + { + var datei = Path.Combine(_ordner, "angaben.mkv"); + Testdateien.MitZweiTonspuren(datei); + + var ergebnis = await _ffprobe.ProbeAsync(datei); + + Assert.That(ergebnis!.Container, Is.EqualTo("mkv")); + Assert.That(ergebnis.Duration, Is.Not.Null); + Assert.That(ergebnis.Duration!.Value.TotalSeconds, Is.EqualTo(1).Within(0.5)); + + var bild = ergebnis.Streams.Single(s => s.Kind == MediaStreamKind.Video); + Assert.That((bild.Width, bild.Height), Is.EqualTo((160, 120))); + } + + /// Die MP4-Familie meldet sechs Namen auf einmal. + [Test] + public async Task Eine_MP4_heisst_mp4_und_nicht_mov_mp4_m4a_3gp_3g2_mj2() + { + var datei = Path.Combine(_ordner, "einfach.mp4"); + Testdateien.Einfach(datei); + + var ergebnis = await _ffprobe.ProbeAsync(datei); + Assert.That(ergebnis!.Container, Is.EqualTo("mp4")); + } + + [Test] + public async Task Eine_Datei_die_es_nicht_gibt_ergibt_nichts() + { + Assert.That(await _ffprobe.ProbeAsync(Path.Combine(_ordner, "gibtesnicht.mkv")), Is.Null); + } + + [Test] + public async Task Eine_Datei_die_kein_Video_ist_ergibt_nichts() + { + var datei = Path.Combine(_ordner, "kaputt.mkv"); + await File.WriteAllTextAsync(datei, "das ist kein Film"); + Assert.That(await _ffprobe.ProbeAsync(datei), Is.Null); + } + + /// Ein Dateiname mit Anführungszeichen darf nichts ausführen. + /// + /// + /// Die Argumente gehen als Liste an den Prozess, nicht als zusammengesetzte + /// Zeile. Sonst bestimmte der Dateiname mit, was ausgeführt wird. + /// + [Test] + public async Task Ein_Dateiname_mit_Anfuehrungszeichen_wird_gelesen() + { + var datei = Path.Combine(_ordner, "Der \"echte\" Film; echo nein.mkv"); + Testdateien.Einfach(datei); + + var ergebnis = await _ffprobe.ProbeAsync(datei); + Assert.That(ergebnis, Is.Not.Null); + Assert.That(ergebnis!.Streams, Is.Not.Empty); + } + + [Test] + public async Task Ein_fehlendes_ffprobe_wirft_nicht() + { + var ohne = new FfprobeRunner( + new FfmpegOptions("ffmpeg", "/gibt/es/nicht/ffprobe"), NullLogger.Instance); + var datei = Path.Combine(_ordner, "egal.mp4"); + Testdateien.Einfach(datei); + + Assert.That(await ohne.ProbeAsync(datei), Is.Null); + } +} diff --git a/server/tests/Halo.Media.Tests/Halo.Media.Tests.csproj b/server/tests/Halo.Media.Tests/Halo.Media.Tests.csproj new file mode 100644 index 0000000..82330b5 --- /dev/null +++ b/server/tests/Halo.Media.Tests/Halo.Media.Tests.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + latest + enable + enable + false + + + + + + + + + + + + + + + + + + + + diff --git a/server/tests/Halo.Media.Tests/RemuxArgumentsTests.cs b/server/tests/Halo.Media.Tests/RemuxArgumentsTests.cs new file mode 100644 index 0000000..bdcebba --- /dev/null +++ b/server/tests/Halo.Media.Tests/RemuxArgumentsTests.cs @@ -0,0 +1,121 @@ +using Halo.Core.Delivery; +using Halo.Core.Library; + +namespace Halo.Media.Tests; + +/// Die ffmpeg-Zeile, betrachtet ohne sie auszuführen. +public class RemuxArgumentsTests +{ + private static ProbedStream Bild() => new(0, MediaStreamKind.Video, "h264", null, null, true, false, null, 1920, 1080); + private static ProbedStream Ton(int i, string codec = "aac", int kanäle = 6) => + new(i, MediaStreamKind.Audio, codec, "deu", null, false, false, kanäle, null, null); + private static ProbedStream Text(int i, string codec = "subrip") => + new(i, MediaStreamKind.Subtitle, codec, "deu", null, false, false, null, null, null); + + private static List Bauen(IReadOnlyList spuren, string schachtel = "mkv") + { + var plan = DeliveryDecider.ForDownload(schachtel, spuren); + return RemuxArguments.For(new RemuxRequest("/medien/film.mkv", "/kratz/film.mp4", spuren, plan)); + } + + private static string AlsZeile(List argumente) => string.Join(' ', argumente); + + /// Jede Spur wird einzeln benannt — und zwar alle. + [Test] + public void Alle_Spuren_werden_abgebildet() + { + var zeile = AlsZeile(Bauen([Bild(), Ton(1), Ton(2), Text(3)])); + + Assert.That(zeile, Does.Contain("-map 0:0")); + Assert.That(zeile, Does.Contain("-map 0:1")); + Assert.That(zeile, Does.Contain("-map 0:2")); + Assert.That(zeile, Does.Contain("-map 0:3")); + } + + [Test] + public void Bild_und_Ton_werden_kopiert_nicht_gerechnet() + { + var zeile = AlsZeile(Bauen([Bild(), Ton(1), Ton(2)])); + + Assert.That(zeile, Does.Contain("-c copy")); + Assert.That(zeile, Does.Not.Contain("-c:a:"), "Nichts davon muss neu gerechnet werden"); + Assert.That(zeile, Does.Not.Contain("libx264"), "Das Bild wird nie angefasst"); + } + + [Test] + public void Textuntertitel_werden_nach_tx3g_umgeschrieben() + { + Assert.That(AlsZeile(Bauen([Bild(), Ton(1), Text(2)])), Does.Contain("-c:s mov_text")); + } + + [Test] + public void Ohne_Untertitel_steht_auch_keine_Untertitel_Anweisung_da() + { + Assert.That(AlsZeile(Bauen([Bild(), Ton(1)])), Does.Not.Contain("-c:s")); + } + + [Test] + public void Bilduntertitel_werden_gar_nicht_erst_abgebildet() + { + var zeile = AlsZeile(Bauen([Bild(), Ton(1), Text(2, "hdmv_pgs_subtitle"), Text(3, "subrip")])); + + Assert.That(zeile, Does.Not.Contain("-map 0:2"), "PGS passt in keine MP4"); + Assert.That(zeile, Does.Contain("-map 0:3")); + } + + /// Die Verwechslung, vor der der Kommentar an MediaStream.Index warnt. + /// + /// + /// -c:a:1 meint die zweite Tonspur der Ausgabe, nicht die Spur + /// Nummer 1 der Datei. Hier steht DTS an Dateinummer 2 und ist die + /// zweite Tonspur — also -c:a:1. Wer die Dateinummer einsetzte, + /// träfe eine andere Spur oder gar keine. + /// + [Test] + public void Neu_gerechnet_wird_nach_der_Nummer_der_Ausgabe() + { + var zeile = AlsZeile(Bauen([Bild(), Ton(1, "aac"), Ton(2, "dts"), Ton(3, "aac")])); + + Assert.That(zeile, Does.Contain("-c:a:1 aac")); + Assert.That(zeile, Does.Not.Contain("-c:a:0"), "Die erste Tonspur passt schon"); + Assert.That(zeile, Does.Not.Contain("-c:a:2"), "Die dritte auch"); + } + + /// Fällt ein Untertitel weg, verschieben sich die Tonnummern nicht. + /// + /// + /// Tonspuren werden unter sich gezählt — ein weggelassener Untertitel + /// ändert daran nichts. Die Prüfung steht hier, weil der umgekehrte Fehler + /// naheliegt und erst an einer stummen Spur auffiele. + /// + [Test] + public void Ein_weggelassener_Untertitel_verschiebt_die_Tonnummern_nicht() + { + var zeile = AlsZeile(Bauen([Bild(), Text(1, "hdmv_pgs_subtitle"), Ton(2, "aac"), Ton(3, "dts")])); + Assert.That(zeile, Does.Contain("-c:a:1 aac")); + } + + [Test] + public void Mehr_Kanaele_bekommen_mehr_Bitrate() + { + Assert.That(AlsZeile(Bauen([Bild(), Ton(1, "dts", kanäle: 2)])), Does.Contain("-b:a:0 192000")); + Assert.That(AlsZeile(Bauen([Bild(), Ton(1, "dts", kanäle: 6)])), Does.Contain("-b:a:0 576000")); + Assert.That(AlsZeile(Bauen([Bild(), Ton(1, "dts", kanäle: 8)])), Does.Contain("-b:a:0 640000"), + "Gedeckelt — acht Kanäle bekämen sonst mehr, als irgendein Gerät braucht"); + } + + [Test] + public void Das_Inhaltsverzeichnis_wandert_nach_vorn() + { + Assert.That(AlsZeile(Bauen([Bild(), Ton(1)])), Does.Contain("-movflags +faststart")); + } + + [Test] + public void Quelle_und_Ziel_stehen_an_der_richtigen_Stelle() + { + var argumente = Bauen([Bild(), Ton(1)]); + + Assert.That(argumente[argumente.IndexOf("-i") + 1], Is.EqualTo("/medien/film.mkv")); + Assert.That(argumente[^1], Is.EqualTo("/kratz/film.mp4"), "Das Ziel steht bei ffmpeg immer zuletzt"); + } +} diff --git a/server/tests/Halo.Media.Tests/RemuxTests.cs b/server/tests/Halo.Media.Tests/RemuxTests.cs new file mode 100644 index 0000000..4b8d740 --- /dev/null +++ b/server/tests/Halo.Media.Tests/RemuxTests.cs @@ -0,0 +1,188 @@ +using Halo.Core.Delivery; +using Halo.Core.Library; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Halo.Media.Tests; + +/// Umpacken, von der echten Datei bis zum Ergebnis. +public class RemuxTests +{ + private string _ordner = ""; + private FfprobeRunner _ffprobe = null!; + private FfmpegRemuxer _umpacker = null!; + + [SetUp] + public void Anlegen() + { + if (!Testdateien.FfmpegVorhanden) Assert.Ignore("ffmpeg ist auf diesem Rechner nicht installiert."); + _ordner = Directory.CreateDirectory( + Path.Combine(Path.GetTempPath(), "halo-umpacken-" + Guid.CreateVersion7().ToString("N"))).FullName; + var optionen = new FfmpegOptions(Testdateien.Suchen("ffmpeg")!, Testdateien.Suchen("ffprobe")!); + _ffprobe = new FfprobeRunner(optionen, NullLogger.Instance); + _umpacker = new FfmpegRemuxer(optionen, NullLogger.Instance); + } + + [TearDown] + public void Aufraeumen() + { + try { if (_ordner.Length > 0) Directory.Delete(_ordner, recursive: true); } catch (IOException) { } + } + + private async Task<(RemuxOutcome Ergebnis, ProbeResult? Danach)> Umpacken( + string quelle, IProgress? fortschritt = null) + { + var davor = await _ffprobe.ProbeAsync(quelle); + Assert.That(davor, Is.Not.Null, "Die Quelldatei liess sich nicht lesen"); + + var plan = DeliveryDecider.ForDownload(davor!.Container, davor.Streams); + var ziel = Path.Combine(_ordner, Path.GetFileNameWithoutExtension(quelle) + ".mp4"); + + var ergebnis = await _umpacker.RunAsync( + new RemuxRequest(quelle, ziel, davor.Streams, plan), davor.Duration, fortschritt); + + return (ergebnis, ergebnis.Success ? await _ffprobe.ProbeAsync(ziel) : null); + } + + /// Der Beweis, auf dem das ganze Projekt ruht. + /// + /// + /// Zwei Tonspuren und ein Untertitel gehen hinein, und alles kommt in + /// der MP4 wieder heraus — samt Sprachkennung und Namen. Genau das + /// kann Jellyfins Transcode-Endpunkt nicht: der bildet je Sitzung eine + /// Tonspur ab, und beim Umpacken verliert er zusätzlich die + /// Sprachkennungen. Dass hier beides erhalten bleibt, ist der Grund für + /// diesen Server. + /// + [Test] + public async Task Alle_Ton_und_Textspuren_ueberstehen_das_Umpacken() + { + var quelle = Path.Combine(_ordner, "zwei-tonspuren.mkv"); + Testdateien.MitZweiTonspuren(quelle); + + var (ergebnis, danach) = await Umpacken(quelle); + + Assert.That(ergebnis.Success, Is.True, ergebnis.Error); + Assert.That(danach!.Container, Is.EqualTo("mp4")); + + var ton = danach.Streams.Where(s => s.Kind == MediaStreamKind.Audio).ToList(); + Assert.That(ton, Has.Count.EqualTo(2), "Beide Tonspuren müssen da sein"); + Assert.That(ton.Select(t => t.Title), Is.EqualTo(new[] { "Deutsch", "Audiodeskription" }), + "Die Namen unterscheiden die beiden — ohne sie wäre keine Auswahl möglich"); + Assert.That(ton.Select(t => t.Language), Is.EqualTo(new[] { "deu", "deu" }), + "Die Sprachkennung überlebt — Jellyfin verliert sie an dieser Stelle"); + + Assert.That(danach.Streams.Count(s => s.Kind == MediaStreamKind.Subtitle), Is.EqualTo(1)); + } + + [Test] + public async Task Der_Untertitel_liegt_danach_als_tx3g_vor() + { + var quelle = Path.Combine(_ordner, "mit-text.mkv"); + Testdateien.MitZweiTonspuren(quelle); + + var (_, danach) = await Umpacken(quelle); + + var text = danach!.Streams.Single(s => s.Kind == MediaStreamKind.Subtitle); + Assert.That(text.Codec, Is.EqualTo("mov_text"), "MP4 kennt nur diese eine Form"); + Assert.That(text.Language, Is.EqualTo("deu")); + } + + /// Nur die untaugliche Spur wird gerechnet, die andere kopiert. + [Test] + public async Task Eine_untaugliche_Tonspur_wird_gerechnet_die_taugliche_nicht() + { + var quelle = Path.Combine(_ordner, "gemischt.mkv"); + Testdateien.MitUntauglichemTon(quelle); + + var davor = await _ffprobe.ProbeAsync(quelle); + Assert.That(davor!.Streams.Select(s => s.Codec), Does.Contain("dts"), "Die Vorlage muss DTS enthalten"); + + var (ergebnis, danach) = await Umpacken(quelle); + Assert.That(ergebnis.Success, Is.True, ergebnis.Error); + + var ton = danach!.Streams.Where(s => s.Kind == MediaStreamKind.Audio).ToList(); + Assert.That(ton, Has.Count.EqualTo(2)); + Assert.That(ton.Select(t => t.Codec), Is.EqualTo(new[] { "aac", "aac" }), + "DTS wurde zu AAC, und die erste Spur blieb, was sie war"); + Assert.That(ton.Select(t => t.Language), Is.EqualTo(new[] { "deu", "eng" }), + "Auch die neu gerechnete Spur behält ihre Sprache"); + } + + /// Das Bild wird nie angefasst — nachgewiesen an der Bildgrösse und am Codec. + [Test] + public async Task Das_Bild_bleibt_unberuehrt() + { + var quelle = Path.Combine(_ordner, "bild.mkv"); + Testdateien.MitZweiTonspuren(quelle); + + var davor = await _ffprobe.ProbeAsync(quelle); + var (_, danach) = await Umpacken(quelle); + + var vorher = davor!.Streams.Single(s => s.Kind == MediaStreamKind.Video); + var nachher = danach!.Streams.Single(s => s.Kind == MediaStreamKind.Video); + + Assert.That(nachher.Codec, Is.EqualTo(vorher.Codec)); + Assert.That((nachher.Width, nachher.Height), Is.EqualTo((vorher.Width, vorher.Height))); + } + + [Test] + public async Task Der_Fortschritt_wird_gemeldet_und_endet_bei_eins() + { + var quelle = Path.Combine(_ordner, "fortschritt.mkv"); + Testdateien.MitZweiTonspuren(quelle); + + var gemeldet = new List(); + var (ergebnis, _) = await Umpacken(quelle, new Progress(w => { lock (gemeldet) gemeldet.Add(w); })); + + Assert.That(ergebnis.Success, Is.True); + // Progress meldet über den Synchronisierungskontext — kurz warten. + await Task.Delay(200); + lock (gemeldet) + { + Assert.That(gemeldet, Is.Not.Empty); + Assert.That(gemeldet[^1], Is.EqualTo(1.0)); + Assert.That(gemeldet, Is.Ordered, "Der Fortschritt darf nicht zurückspringen"); + } + } + + /// Eine halbe Datei ist schlimmer als keine. + /// + /// + /// Bliebe sie liegen, hielte der nächste Aufruf sie für fertig und gäbe + /// einen abgeschnittenen Film heraus — der beim Abspielen mittendrin + /// abbricht. + /// + [Test] + public async Task Nach_einem_Fehlschlag_liegt_keine_halbe_Datei_herum() + { + var quelle = Path.Combine(_ordner, "kaputt.mkv"); + await File.WriteAllTextAsync(quelle, "das ist kein Film"); + var ziel = Path.Combine(_ordner, "kaputt.mp4"); + + var ergebnis = await _umpacker.RunAsync(new RemuxRequest( + quelle, ziel, [], new DeliveryPlan(DeliveryMethod.Remux, [], [], []))); + + Assert.That(ergebnis.Success, Is.False); + Assert.That(ergebnis.Error, Is.Not.Null.And.Not.Empty); + Assert.That(File.Exists(ziel), Is.False); + } + + [Test] + public async Task Ein_Abbruch_laesst_nichts_zurueck() + { + var quelle = Path.Combine(_ordner, "abbruch.mkv"); + Testdateien.MitZweiTonspuren(quelle); + var ziel = Path.Combine(_ordner, "abbruch.mp4"); + + var davor = await _ffprobe.ProbeAsync(quelle); + using var abbruch = new CancellationTokenSource(); + await abbruch.CancelAsync(); + + Assert.ThrowsAsync(() => _umpacker.RunAsync( + new RemuxRequest(quelle, ziel, davor!.Streams, + DeliveryDecider.ForDownload(davor.Container, davor.Streams)), + ct: abbruch.Token)); + + Assert.That(File.Exists(ziel), Is.False); + } +} diff --git a/server/tests/Halo.Media.Tests/Testdateien.cs b/server/tests/Halo.Media.Tests/Testdateien.cs new file mode 100644 index 0000000..d0c1d3f --- /dev/null +++ b/server/tests/Halo.Media.Tests/Testdateien.cs @@ -0,0 +1,97 @@ +using System.Diagnostics; + +namespace Halo.Media.Tests; + +/// Baut echte Mediendateien mit ffmpeg. +/// +/// +/// Echte Dateien und keine aufgezeichneten Antworten. Bei TMDB ist das +/// umgekehrt richtig — dort steht ein fremder Dienst dahinter. Hier geht es +/// um das Werkzeug, das Halo im Container ohnehin mitbringt, und die Frage +/// „liest er die zweite Tonspur mitsamt Sprache?" lässt sich nur an einer +/// Datei beantworten, die wirklich zwei hat. +/// +public static class Testdateien +{ + public static bool FfmpegVorhanden => Suchen("ffmpeg") is not null && Suchen("ffprobe") is not null; + + public static string? Suchen(string name) + { + foreach (var ordner in (Environment.GetEnvironmentVariable("PATH") ?? "").Split(Path.PathSeparator)) + { + if (ordner.Length == 0) continue; + var pfad = Path.Combine(ordner, name); + if (File.Exists(pfad)) return pfad; + } + return null; + } + + /// Zwei Tonspuren mit Sprache und Namen, dazu ein Untertitel. + public static void MitZweiTonspuren(string ziel) + { + var untertitel = Path.ChangeExtension(ziel, ".srt"); + File.WriteAllText(untertitel, + "1\n00:00:00,000 --> 00:00:01,000\nEin Untertitel\n\n"); + + Ausführen("ffmpeg", + [ + "-y", "-loglevel", "error", + "-f", "lavfi", "-i", "testsrc=duration=1:size=160x120:rate=10", + "-f", "lavfi", "-i", "sine=frequency=440:duration=1", + "-f", "lavfi", "-i", "sine=frequency=880:duration=1", + "-i", untertitel, + "-map", "0:v", "-map", "1:a", "-map", "2:a", "-map", "3:s", + "-metadata:s:a:0", "language=deu", "-metadata:s:a:0", "title=Deutsch", + "-metadata:s:a:1", "language=deu", "-metadata:s:a:1", "title=Audiodeskription", + "-metadata:s:s:0", "language=deu", + "-c:v", "libx264", "-preset", "ultrafast", "-c:a", "aac", "-c:s", "srt", + ziel, + ]); + File.Delete(untertitel); + } + + /// Eine schlichte MP4 mit einer Tonspur. + public static void Einfach(string ziel) => Ausführen("ffmpeg", + [ + "-y", "-loglevel", "error", + "-f", "lavfi", "-i", "testsrc=duration=1:size=160x120:rate=10", + "-f", "lavfi", "-i", "sine=frequency=440:duration=1", + "-c:v", "libx264", "-preset", "ultrafast", "-c:a", "aac", + ziel, + ]); + + /// Eine MKV mit einer Tonspur in einem Codec, den Apple nicht kennt. + /// + /// + /// DTS, also der echte Fall. So liegen Blu-ray-Rips vor, und genau + /// daran entscheidet sich, ob eine sonst tadellose Datei stumm bliebe. + /// Der Encoder gilt bei ffmpeg als erprobungsweise und braucht deshalb + /// -strict -2 — das betrifft nur das Erzeugen dieser Testvorlage, + /// nicht Halo. + /// + public static void MitUntauglichemTon(string ziel) => Ausführen("ffmpeg", + [ + "-y", "-loglevel", "error", + "-f", "lavfi", "-i", "testsrc=duration=1:size=160x120:rate=10", + "-f", "lavfi", "-i", "sine=frequency=440:duration=1", + "-f", "lavfi", "-i", "sine=frequency=880:duration=1", + "-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", + "-c:a:0", "aac", "-c:a:1", "dca", "-strict", "-2", + ziel, + ]); + + private static void Ausführen(string werkzeug, string[] argumente) + { + var start = new ProcessStartInfo(Suchen(werkzeug)!) { 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($"{werkzeug} scheiterte: {fehler}"); + } +}