Stufe 3: Download mit allen Ton- und Textspuren

**Der Grund, warum es diesen Server gibt.** Eine MKV mit zwei Tonspuren geht
hinein, eine MP4 mit zwei Tonspuren kommt heraus — mitsamt Sprachkennung und
Spurnamen. Im Container nachgewiesen, nicht nur im Test:

    0 video     h264     und
    1 audio     aac      deu   Deutsch
    2 audio     aac      deu   Audiodeskription
    3 subtitle  mov_text deu

Jellyfins Transcode-Endpunkt bildet je Sitzung **eine** Tonspur ab und
verliert beim Umpacken zusätzlich die Sprachkennungen. Der Plan hier hat gar
kein Feld „welche Tonspur" — es gibt nichts auszuwählen, weil alle mitgehen.

**Vorher fragen können.** `GET /files/{id}/delivery` sagt, was geschehen wird:
umgepackt oder nicht, welche Tonspuren neu gerechnet werden müssen, welche
Untertitel nicht mitkönnen. Drei Gigabyte zu laden und erst danach zu merken,
dass die falsche Tonspur drin ist, war der Anlass für das Ganze.

**Umgepackt wird auf die Platte, nicht in die Antwort.** Der bequeme Weg wäre,
ffmpeg durchzureichen. Dann kennt aber niemand die Länge im Voraus: kein
Fortschrittsbalken, kein Fortsetzen, kein Springen. Jetzt kommt beim ersten
Abruf ein 202 mit Fortschritt, danach die fertige Datei mit `Content-Length`
und `Accept-Ranges`.

**Bildbasierte Untertitel fallen weg, und das steht im Plan.** PGS passt in
keine MP4 — betrifft fast jeden Blu-ray-Rip. Stillschweigend wegzulassen
hiesse, jemanden nach Untertiteln suchen zu lassen, die der Server bewusst
entfernt hat.

---

**Zwei Funde aus echten Messungen:**

*Die Spurnamen überlebten das Umpacken nicht* — „Deutsch" kam als `null` an,
und damit wären zwei deutsche Tonspuren hinterher nicht mehr zu unterscheiden
gewesen. Ein Versuch an einer echten Datei zeigte den Grund: **MP4 nennt das
Feld `name`, Matroska nennt es `title`.** ffmpeg schreibt das eine ins andere
um; mein Leser kannte nur `title`. Keine Grenze des Formats, ein Lesefehler.
`handler_name` steht ausdrücklich nicht in der Suche — dort schreibt ffmpeg
„SoundHandler" hinein.

*Der Image-Bau brach mit Code 100 ab.* `intel-media-va-driver` gibt es für
arm64 nicht, und die Meldung verrät nicht, welches von sechs Paketen fehlt.
Die Intel-Treiber kommen jetzt nur auf amd64 dazu; `mesa-va-drivers` bedient
AMD auf beiden.

---

**Die ffmpeg-Zeile ist eine reine Funktion und wird ohne Ausführen geprüft.**
Hier liegt die Erfahrung dieser Woche: eine falsche Parameterkombination
kostete Tage, weil sie nur gegen einen echten Server zu entdecken war. Dazu
gehört die Verwechslung, vor der jetzt ein Test steht: `-c:a:1` meint die
zweite Tonspur der **Ausgabe**, nicht Spur 1 der Datei.

ffmpeg läuft als eigener Prozess, nicht als eingebundene Bibliothek — das
hält die Lizenzen getrennt und lässt einen Absturz dort den Server nicht
mitnehmen.

223 Tests grün (98 Kern, 31 Metadaten, 26 Medien, 68 Schnittstelle).
Image baut für amd64 und arm64, mit ffmpeg 6.1.1 und VAAPI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-03 17:14:55 +02:00
co-authored by Claude Opus 5
parent deefc79fc1
commit e4a90fce4e
32 changed files with 2840 additions and 17 deletions
+22
View File
@@ -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 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.** # **Die Verzeichnisse müssen hier angelegt werden, nicht erst beim Start.**
# Docker legt den Einhängepunkt eines frischen benannten Volumes sonst als # 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 — # `root` an und übernimmt für ein leeres Volume die Rechte aus dem Image —
+2
View File
@@ -3,11 +3,13 @@
<Project Path="src/Halo.Api/Halo.Api.csproj" /> <Project Path="src/Halo.Api/Halo.Api.csproj" />
<Project Path="src/Halo.Core/Halo.Core.csproj" /> <Project Path="src/Halo.Core/Halo.Core.csproj" />
<Project Path="src/Halo.Data/Halo.Data.csproj" /> <Project Path="src/Halo.Data/Halo.Data.csproj" />
<Project Path="src/Halo.Media/Halo.Media.csproj" />
<Project Path="src/Halo.Metadata/Halo.Metadata.csproj" /> <Project Path="src/Halo.Metadata/Halo.Metadata.csproj" />
</Folder> </Folder>
<Folder Name="/tests/"> <Folder Name="/tests/">
<Project Path="tests/Halo.Api.Tests/Halo.Api.Tests.csproj" /> <Project Path="tests/Halo.Api.Tests/Halo.Api.Tests.csproj" />
<Project Path="tests/Halo.Core.Tests/Halo.Core.Tests.csproj" /> <Project Path="tests/Halo.Core.Tests/Halo.Core.Tests.csproj" />
<Project Path="tests/Halo.Media.Tests/Halo.Media.Tests.csproj" />
<Project Path="tests/Halo.Metadata.Tests/Halo.Metadata.Tests.csproj" /> <Project Path="tests/Halo.Metadata.Tests/Halo.Metadata.Tests.csproj" />
</Folder> </Folder>
</Solution> </Solution>
+41 -4
View File
@@ -3,9 +3,10 @@
Ein Medienserver, der dieselben Dinge kann wie Jellyfin — und zwei, die dort Ein Medienserver, der dieselben Dinge kann wie Jellyfin — und zwei, die dort
nicht gehen. nicht gehen.
**Stand: Stufe 2 von 5.** Benutzer, Anmeldung, Einrichtung, und er liest den **Stand: Stufe 3 von 5.** Benutzer, Anmeldung, Einrichtung, Bestand einlesen,
Bestand: Dateien finden, Namen erkennen, Metadaten und Bilder von TMDB holen. Metadaten — und **Download mit allen Ton- und Textspuren**, der Grund für
Abspielen kann er noch nicht — das ist Stufe 3. Der Entwurf samt Begründungen steht 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). in [`docs/superpowers/specs/2026-10-03-halo-server-design.md`](../docs/superpowers/specs/2026-10-03-halo-server-design.md).
## Warum ## Warum
@@ -67,7 +68,7 @@ hilft.
## Entwickeln ## Entwickeln
```bash ```bash
dotnet test # 180 Tests dotnet test # 223 Tests
dotnet run --project src/Halo.Api # auf http://localhost:5000 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.Core` | Domäne: Benutzer, Anmeldung, Bibliotheken. Kennt kein HTTP und keine Datenbank |
| `Halo.Data` | EF Core, SQLite, Migrationen | | `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.Metadata` | TMDB und die Bildablage. Hinter einer Schnittstelle, damit alles andere ohne Netz prüfbar bleibt |
| `Halo.Api` | Endpunkte, Authentifizierung, Auslieferung der Oberfläche | | `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 holt sie bei Halo, nicht bei TMDB: sonst bräuchte sie dafür Internet, und TMDB
erführe, wer wann was ansieht. 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 ## Anmeldung am Fernseher
Ein Passwort auf der Apple-TV-Fernbedienung zu tippen ist eine Zumutung. Ein Passwort auf der Apple-TV-Fernbedienung zu tippen ist eine Zumutung.
+35 -1
View File
@@ -45,6 +45,7 @@ public sealed record ScanStatus(
int? Updated, int? Updated,
int? Removed, int? Removed,
int? Unrecognised, int? Unrecognised,
int? Probed,
IReadOnlyList<ScanProblem>? Problems, IReadOnlyList<ScanProblem>? Problems,
string? Error, string? Error,
MetadataProgress? Metadata); MetadataProgress? Metadata);
@@ -53,7 +54,22 @@ public sealed record ScanStatus(
public sealed record MetadataProgress( public sealed record MetadataProgress(
int MoviesMatched, int SeriesMatched, int EpisodesMatched, bool Skipped, string? Error); int MoviesMatched, int SeriesMatched, int EpisodesMatched, bool Skipped, string? Error);
public sealed record MediaFileResponse(Guid Id, string Path, long SizeBytes); /// <summary>Eine Spur in einer Datei.</summary>
public sealed record MediaStreamResponse(
int Index, string Kind, string? Codec, string? Language, string? Title,
bool IsDefault, bool IsForced, int? Channels, int? Width, int? Height);
/// <summary>Eine Datei hinter einem Titel — eine Fassung also.</summary>
///
/// <remarks>
/// <b>Nur der Dateiname, nicht der Pfad.</b> 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.
/// </remarks>
public sealed record MediaFileResponse(
Guid Id, string FileName, long SizeBytes, string? Container, double? DurationSeconds,
IReadOnlyList<MediaStreamResponse> Streams);
public sealed record MovieResponse( public sealed record MovieResponse(
Guid Id, string Title, int? Year, string? Overview, Guid Id, string Title, int? Year, string? Overview,
@@ -74,3 +90,21 @@ public sealed record Page<T>(IReadOnlyList<T> Items, int Total);
public sealed record MetadataSettingsResponse(bool HasApiKey, string Language); public sealed record MetadataSettingsResponse(bool HasApiKey, string Language);
public sealed record MetadataSettingsRequest(string? ApiKey, string? Language); public sealed record MetadataSettingsRequest(string? ApiKey, string? Language);
// --- Stufe 3: Ausliefern -------------------------------------------------
/// <summary>Was mit einer Datei geschehen wird, bevor sie hinausgeht.</summary>
///
/// <remarks>
/// <b>Damit vorher feststeht, was man bekommt.</b> 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.
/// </remarks>
public sealed record DeliveryInfo(
string Method,
IReadOnlyList<string> Reasons,
IReadOnlyList<int> AudioToEncode,
IReadOnlyList<int> SubtitlesDropped,
string PackageState,
double Progress,
string? Error);
@@ -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..];
}
@@ -20,6 +20,31 @@ public static class LibraryContentEndpoints
MapBrowse(app); MapBrowse(app);
} }
/// <summary>Holt die Spuren aller genannten Dateien in einer Abfrage.</summary>
///
/// <remarks>
/// Je Datei einzeln zu fragen hiesse: fünfzig Titel, fünfzig Abfragen —
/// und SQLite führt die nacheinander aus.
/// </remarks>
private static async Task<ILookup<Guid, MediaStreamResponse>> SpurenZu(HaloContext db, List<Guid> 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<Guid, MediaStreamResponse> spuren) =>
new(id, System.IO.Path.GetFileName(pfad), bytes, schachtel, dauer?.TotalSeconds, spuren[id].ToList());
private static void MapScan(IEndpointRouteBuilder app) private static void MapScan(IEndpointRouteBuilder app)
{ {
app.MapPost("/api/v1/libraries/{id:guid}/scan", async ( app.MapPost("/api/v1/libraries/{id:guid}/scan", async (
@@ -38,13 +63,14 @@ public static class LibraryContentEndpoints
{ {
var zustand = koordinator.State(id); var zustand = koordinator.State(id);
if (zustand is null) 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 bericht = zustand.Report;
var metadaten = zustand.Enrichment; var metadaten = zustand.Enrichment;
return Results.Ok(new ScanStatus( return Results.Ok(new ScanStatus(
zustand.Status, zustand.StartedAt, zustand.FinishedAt, zustand.Status, zustand.StartedAt, zustand.FinishedAt,
bericht?.FilesFound, bericht?.Added, bericht?.Updated, bericht?.Removed, bericht?.Unrecognised, bericht?.FilesFound, bericht?.Added, bericht?.Updated, bericht?.Removed, bericht?.Unrecognised,
bericht?.Probed,
bericht?.Problems.Select(p => new ScanProblem(p.Path, p.Reason)).ToList(), bericht?.Problems.Select(p => new ScanProblem(p.Path, p.Reason)).ToList(),
zustand.Error, zustand.Error,
metadaten is null ? null : new MetadataProgress( metadaten is null ? null : new MetadataProgress(
@@ -76,13 +102,14 @@ public static class LibraryContentEndpoints
var ids = kennungen.Select(m => m.Id).ToList(); var ids = kennungen.Select(m => m.Id).ToList();
var dateien = await db.MediaFiles.AsNoTracking() var dateien = await db.MediaFiles.AsNoTracking()
.Where(f => f.MovieId != null && ids.Contains(f.MovieId!.Value)) .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(); .ToListAsync();
var spuren = await SpurenZu(db, dateien.Select(f => f.Id).ToList());
var items = kennungen.Select(m => new MovieResponse( var items = kennungen.Select(m => new MovieResponse(
m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage, m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage,
dateien.Where(f => f.MovieId == m.Id) 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<MovieResponse>(items, gesamt)); return Results.Ok(new Page<MovieResponse>(items, gesamt));
}).RequireAuthorization(); }).RequireAuthorization();
@@ -134,13 +161,14 @@ public static class LibraryContentEndpoints
var ids = folgen.Select(f => f.Id).ToList(); var ids = folgen.Select(f => f.Id).ToList();
var dateien = await db.MediaFiles.AsNoTracking() var dateien = await db.MediaFiles.AsNoTracking()
.Where(d => d.EpisodeId != null && ids.Contains(d.EpisodeId!.Value)) .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(); .ToListAsync();
var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList());
var items = folgen.Select(f => new EpisodeResponse( var items = folgen.Select(f => new EpisodeResponse(
f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview, f.StillImage, f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview, f.StillImage,
dateien.Where(d => d.EpisodeId == f.Id) 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<EpisodeResponse>(items, items.Count)); return Results.Ok(new Page<EpisodeResponse>(items, items.Count));
}).RequireAuthorization(); }).RequireAuthorization();
+1
View File
@@ -4,6 +4,7 @@
<ProjectReference Include="..\Halo.Core\Halo.Core.csproj" /> <ProjectReference Include="..\Halo.Core\Halo.Core.csproj" />
<ProjectReference Include="..\Halo.Data\Halo.Data.csproj" /> <ProjectReference Include="..\Halo.Data\Halo.Data.csproj" />
<ProjectReference Include="..\Halo.Metadata\Halo.Metadata.csproj" /> <ProjectReference Include="..\Halo.Metadata\Halo.Metadata.csproj" />
<ProjectReference Include="..\Halo.Media\Halo.Media.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
+156
View File
@@ -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);
/// <summary>Packt Dateien im Hintergrund um und weiss, wie weit das ist.</summary>
///
/// <remarks>
/// <para>
/// <b>Erst auf die Platte, dann hinaus — nicht unmittelbar durchgereicht.</b>
/// 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.
/// </para>
/// <para>
/// Der Preis ist Plattenplatz und Wartezeit vor dem ersten Byte. Dafür gibt
/// es <c>HALO_TRANSCODE</c>, und dafür meldet dieser Weg einen Fortschritt.
/// </para>
/// </remarks>
public sealed class PackageCoordinator(
IServiceScopeFactory scopes,
HaloPaths pfade,
TimeProvider clock,
ILogger<PackageCoordinator> log)
{
private readonly ConcurrentDictionary<Guid, PackageState> _zustand = new();
/// <summary>Wie viel freier Platz über die Dateigrösse hinaus bleiben muss.</summary>
///
/// <remarks>
/// 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.
/// </remarks>
private const long Sicherheitsabstand = 2L * 1024 * 1024 * 1024;
public PackageState? State(Guid fileId) => _zustand.GetValueOrDefault(fileId);
/// <summary>Wohin die umgepackte Datei kommt.</summary>
///
/// <remarks>
/// <b>Der Änderungszeitpunkt steckt im Namen.</b> Wird die Quelle ersetzt,
/// heisst das Ergebnis anders — und niemand bekommt die alte Fassung
/// ausgeliefert, bloss weil sie zufällig noch herumliegt.
/// </remarks>
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));
/// <summary>Startet einen Lauf — oder sagt, dass schon einer läuft.</summary>
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<HaloContext>();
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<double>(wert =>
_zustand.AddOrUpdate(fileId,
_ => new PackageState("running", wert, clock.GetUtcNow(), null, null),
(_, alt) => alt with { Progress = wert }));
var umpacker = bereich.ServiceProvider.GetRequiredService<FfmpegRemuxer>();
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);
}
}
/// <summary>Bricht ab, bevor die Platte volläuft.</summary>
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);
}
}
/// <summary>Löscht frühere Fassungen derselben Datei.</summary>
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); }
}
}
}
+10
View File
@@ -4,6 +4,8 @@ using Halo.Api.Auth;
using Halo.Api.Endpoints; using Halo.Api.Endpoints;
using Halo.Core.Metadata; using Halo.Core.Metadata;
using Halo.Data; using Halo.Data;
using Halo.Core.Library;
using Halo.Media;
using Halo.Metadata; using Halo.Metadata;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
@@ -59,6 +61,12 @@ builder.Services.AddRateLimiter(o =>
_ => new FixedWindowRateLimiterOptions { PermitLimit = beimNachfragen, Window = TimeSpan.FromMinutes(1) })); _ => 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<IMediaProbe, FfprobeRunner>();
builder.Services.AddSingleton<FfmpegRemuxer>();
builder.Services.AddScoped<LibraryScanner>(); builder.Services.AddScoped<LibraryScanner>();
// Metadaten. Der Anbieter steckt hinter einer Schnittstelle, damit alles, was // Metadaten. Der Anbieter steckt hinter einer Schnittstelle, damit alles, was
@@ -76,6 +84,7 @@ builder.Services.AddHttpClient<ImageCache>(c => c.Timeout = TimeSpan.FromSeconds
builder.Services.AddScoped<IImageStore>(sp => sp.GetRequiredService<ImageCache>()); builder.Services.AddScoped<IImageStore>(sp => sp.GetRequiredService<ImageCache>());
builder.Services.AddScoped<MetadataEnricher>(); builder.Services.AddScoped<MetadataEnricher>();
builder.Services.AddSingleton<ScanCoordinator>(); builder.Services.AddSingleton<ScanCoordinator>();
builder.Services.AddSingleton<PackageCoordinator>();
builder.Services.AddProblemDetails(); builder.Services.AddProblemDetails();
var app = builder.Build(); var app = builder.Build();
@@ -99,6 +108,7 @@ app.MapManagement();
app.MapLibraryContent(); app.MapLibraryContent();
app.MapSettings(); app.MapSettings();
app.MapImages(); app.MapImages();
app.MapDelivery();
app.Run(); app.Run();
@@ -0,0 +1,136 @@
using Halo.Core.Library;
namespace Halo.Core.Delivery;
public enum DeliveryMethod
{
/// <summary>Die Datei geht unverändert hinaus.</summary>
DirectFile,
/// <summary>Die Spuren wandern in eine MP4 — Bild und meist auch Ton unberührt.</summary>
Remux,
}
public enum DeliveryReason
{
NativelySupported,
ContainerUnsupported,
VideoCodecUnsupported,
AudioCodecUnsupported,
/// <summary>Untertitel müssen für MP4 nach tx3g umgeschrieben werden.</summary>
SubtitlesNeedConversion,
/// <summary>Bildbasierte Untertitel passen in keine MP4 und fallen weg.</summary>
ImageSubtitlesDropped,
}
/// <summary>Was mit einer Datei geschehen muss, damit das Gerät sie öffnet.</summary>
public sealed record DeliveryPlan(
DeliveryMethod Method,
IReadOnlyList<DeliveryReason> Reasons,
/// <summary>Tonspuren, die neu kodiert werden müssen — Nummern wie in der Datei.</summary>
IReadOnlyList<int> AudioToEncode,
/// <summary>Textspuren, die nicht mitkönnen.</summary>
IReadOnlyList<int> SubtitlesDropped)
{
public bool NeedsWork => Method != DeliveryMethod.DirectFile;
}
/// <summary>Entscheidet, ob eine Datei unverändert hinausgehen kann.</summary>
///
/// <remarks>
/// <para>
/// <b>Das Bild wird nie angefasst.</b> 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.
/// </para>
/// <para>
/// <b>Was fehlt, zählt als passend.</b> Nennt die Datei keinen Codec, ist das
/// keine Aussage über seine Untauglichkeit. Vorsichtshalber umzupacken hiesse,
/// aus einer tadellosen MP4 eine neue Datei zu machen.
/// </para>
/// </remarks>
public static class DeliveryDecider
{
/// <summary>Untertitel, die als Bild vorliegen — MP4 hat dafür keinen Platz.</summary>
///
/// <remarks>
/// <b>Betrifft fast jeden Blu-ray-Rip.</b> 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.
/// </remarks>
private static readonly HashSet<string> BildUntertitel = new(StringComparer.OrdinalIgnoreCase)
{ "hdmv_pgs_subtitle", "pgssub", "dvd_subtitle", "dvdsub", "xsub" };
/// <summary>Textuntertitel, die sich nach tx3g umschreiben lassen.</summary>
private static readonly HashSet<string> TextUntertitel = new(StringComparer.OrdinalIgnoreCase)
{ "subrip", "srt", "ass", "ssa", "mov_text", "webvtt", "text" };
public static DeliveryPlan ForDownload(
string? container, IReadOnlyList<ProbedStream> spuren, DeviceProfile? profil = null)
{
profil ??= DeviceProfile.AppleDefault;
var gründe = new List<DeliveryReason>();
var neuZuRechnen = new List<int>();
var fallenWeg = new List<int>();
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<string> erlaubt) =>
string.IsNullOrWhiteSpace(wert) || erlaubt.Contains(wert);
private static List<DeliveryReason> Eindeutig(List<DeliveryReason> gründe) =>
gründe.Distinct().ToList();
}
@@ -0,0 +1,41 @@
namespace Halo.Core.Delivery;
/// <summary>Was ein Gerät von sich aus öffnen kann.</summary>
///
/// <remarks>
/// <para>
/// <b>Diese Liste steht hier ein zweites Mal.</b> 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.
/// </para>
/// <para>
/// Der Client schickt sein Profil künftig selbst mit. Bis dahin gilt
/// <see cref="AppleDefault"/>, und das ist keine Verlegenheit: es ist genau
/// die Liste, an der sich entscheidet, ob umgepackt werden muss.
/// </para>
/// </remarks>
public sealed record DeviceProfile(
IReadOnlySet<string> Containers,
IReadOnlySet<string> VideoCodecs,
IReadOnlySet<string> AudioCodecs)
{
/// <summary>Was AVFoundation auf iOS, tvOS und macOS öffnet.</summary>
///
/// <remarks>
/// <b>Kein mkv.</b> 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.
///
/// <b>Kein DTS, kein TrueHD, kein Vorbis.</b> Die drei Gründe, warum ein
/// sonst tadelloser Film stumm bliebe.
/// </remarks>
public static readonly DeviceProfile AppleDefault = new(
Containers: new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "mp4", "m4v", "mov" },
VideoCodecs: new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{ "h264", "avc1", "hevc", "h265", "hvc1", "dvh1", "dvhe" },
AudioCodecs: new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{ "aac", "mp3", "ac3", "eac3", "alac", "pcm", "lpcm", "flac" });
}
@@ -0,0 +1,33 @@
namespace Halo.Core.Library;
/// <summary>Was in einer Datei steckt.</summary>
public sealed record ProbeResult(
string? Container,
TimeSpan? Duration,
long? BitRate,
IReadOnlyList<ProbedStream> 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);
/// <summary>Liest die technischen Angaben einer Datei.</summary>
///
/// <remarks>
/// Als Schnittstelle, damit alles darauf Aufbauende ohne ffmpeg prüfbar
/// bleibt — aus demselben Grund wie beim Metadaten-Anbieter. <c>null</c>
/// heisst: nicht lesbar. Das ist kein Abbruch, sondern eine Datei, über die
/// nichts bekannt ist.
/// </remarks>
public interface IMediaProbe
{
Task<ProbeResult?> ProbeAsync(string pfad, CancellationToken ct = default);
}
@@ -35,6 +35,24 @@ public sealed class MediaFile
public Guid? MovieId { get; set; } public Guid? MovieId { get; set; }
public Guid? EpisodeId { get; set; } public Guid? EpisodeId { get; set; }
// --- Was ffprobe gelesen hat -----------------------------------------
/// <summary>Die Schachtel: mkv, mp4, ts …</summary>
public string? Container { get; set; }
public TimeSpan? Duration { get; set; }
public long? BitRate { get; set; }
/// <summary>Wann zuletzt untersucht — <c>null</c>, solange nie.</summary>
///
/// <remarks>
/// 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.
/// </remarks>
public DateTimeOffset? ProbedAt { get; set; }
} }
public sealed class Movie public sealed class Movie
@@ -0,0 +1,59 @@
namespace Halo.Core.Library;
public enum MediaStreamKind { Video, Audio, Subtitle }
/// <summary>Eine Spur in einer Datei, so wie ffprobe sie beschreibt.</summary>
///
/// <remarks>
/// <para>
/// <b>Hier entscheidet sich alles Weitere.</b> 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 — <i>welche Tonspuren gibt es überhaupt</i> — ist ohne diese
/// Tabelle nicht zu beantworten.
/// </para>
/// </remarks>
public sealed class MediaStream
{
public Guid Id { get; init; } = Guid.CreateVersion7();
public required Guid MediaFileId { get; init; }
/// <summary>Die Nummer, unter der ffmpeg die Spur anspricht.</summary>
///
/// <remarks>
/// 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 <c>-map 0:a:1</c> eine Falle.
/// </remarks>
public required int Index { get; init; }
public required MediaStreamKind Kind { get; init; }
public string? Codec { get; init; }
/// <summary>Die Sprache, wie die Datei sie führt — meist dreibuchstabig.</summary>
///
/// <remarks>
/// <c>null</c> oder <c>und</c> 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.
/// </remarks>
public string? Language { get; init; }
/// <summary>Der Name der Spur, falls die Datei einen führt.</summary>
///
/// <remarks>
/// Oft die einzige Stelle, an der „Audiodeskription" oder „Kommentar"
/// steht — die Sprachkennung sagt bei beiden nur „deu".
/// </remarks>
public string? Title { get; init; }
public bool IsDefault { get; init; }
public bool IsForced { get; init; }
/// <summary>Nur bei Ton: wie viele Kanäle.</summary>
public int? Channels { get; init; }
/// <summary>Nur bei Bild.</summary>
public int? Width { get; init; }
public int? Height { get; init; }
}
+16
View File
@@ -28,3 +28,19 @@ public sealed class DateTimeOffsetToUnixMilliseconds() : ValueConverter<DateTime
public sealed class NullableDateTimeOffsetToUnixMilliseconds() : ValueConverter<DateTimeOffset?, long?>( public sealed class NullableDateTimeOffsetToUnixMilliseconds() : ValueConverter<DateTimeOffset?, long?>(
zeitpunkt => zeitpunkt == null ? null : zeitpunkt.Value.ToUnixTimeMilliseconds(), zeitpunkt => zeitpunkt == null ? null : zeitpunkt.Value.ToUnixTimeMilliseconds(),
zahl => zahl == null ? null : DateTimeOffset.FromUnixTimeMilliseconds(zahl.Value)); zahl => zahl == null ? null : DateTimeOffset.FromUnixTimeMilliseconds(zahl.Value));
/// <summary>Zeitspannen als Millisekunden.</summary>
///
/// <remarks>
/// Aus demselben Grund wie bei den Zeitpunkten: SQLite legt eine
/// <c>TimeSpan</c> sonst als Text ab, und darauf lässt sich nicht sinnvoll
/// vergleichen oder sortieren — „nach Länge sortieren" wäre damit unmöglich.
/// </remarks>
public sealed class TimeSpanToMilliseconds() : ValueConverter<TimeSpan, long>(
spanne => (long)spanne.TotalMilliseconds,
zahl => TimeSpan.FromMilliseconds(zahl));
/// <inheritdoc cref="TimeSpanToMilliseconds"/>
public sealed class NullableTimeSpanToMilliseconds() : ValueConverter<TimeSpan?, long?>(
spanne => spanne == null ? null : (long)spanne.Value.TotalMilliseconds,
zahl => zahl == null ? null : TimeSpan.FromMilliseconds(zahl.Value));
+17
View File
@@ -31,6 +31,7 @@ public sealed class HaloContext(DbContextOptions<HaloContext> options) : DbConte
public DbSet<Movie> Movies => Set<Movie>(); public DbSet<Movie> Movies => Set<Movie>();
public DbSet<Series> Series => Set<Series>(); public DbSet<Series> Series => Set<Series>();
public DbSet<Episode> Episodes => Set<Episode>(); public DbSet<Episode> Episodes => Set<Episode>();
public DbSet<MediaStream> MediaStreams => Set<MediaStream>();
/// <summary>Gilt für jede Zeitangabe im Modell, ohne sie einzeln zu nennen.</summary> /// <summary>Gilt für jede Zeitangabe im Modell, ohne sie einzeln zu nennen.</summary>
/// ///
@@ -43,6 +44,8 @@ public sealed class HaloContext(DbContextOptions<HaloContext> options) : DbConte
{ {
builder.Properties<DateTimeOffset>().HaveConversion<DateTimeOffsetToUnixMilliseconds>(); builder.Properties<DateTimeOffset>().HaveConversion<DateTimeOffsetToUnixMilliseconds>();
builder.Properties<DateTimeOffset?>().HaveConversion<NullableDateTimeOffsetToUnixMilliseconds>(); builder.Properties<DateTimeOffset?>().HaveConversion<NullableDateTimeOffsetToUnixMilliseconds>();
builder.Properties<TimeSpan>().HaveConversion<TimeSpanToMilliseconds>();
builder.Properties<TimeSpan?>().HaveConversion<NullableTimeSpanToMilliseconds>();
} }
protected override void OnModelCreating(ModelBuilder b) protected override void OnModelCreating(ModelBuilder b)
@@ -107,6 +110,7 @@ public sealed class HaloContext(DbContextOptions<HaloContext> options) : DbConte
e.HasIndex(x => x.MovieId); e.HasIndex(x => x.MovieId);
e.HasIndex(x => x.EpisodeId); e.HasIndex(x => x.EpisodeId);
e.Property(x => x.Path).HasMaxLength(1024); e.Property(x => x.Path).HasMaxLength(1024);
e.Property(x => x.Container).HasMaxLength(32);
e.HasOne<Core.Libraries.Library>().WithMany().HasForeignKey(x => x.LibraryId) e.HasOne<Core.Libraries.Library>().WithMany().HasForeignKey(x => x.LibraryId)
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
}); });
@@ -131,6 +135,19 @@ public sealed class HaloContext(DbContextOptions<HaloContext> options) : DbConte
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
}); });
b.Entity<MediaStream>(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<string>().HasMaxLength(16);
e.HasOne<MediaFile>().WithMany().HasForeignKey(x => x.MediaFileId).OnDelete(DeleteBehavior.Cascade);
});
b.Entity<Episode>(e => b.Entity<Episode>(e =>
{ {
e.HasKey(x => x.Id); e.HasKey(x => x.Id);
+72 -7
View File
@@ -11,6 +11,7 @@ public sealed record ScanReport(
int Updated, int Updated,
int Removed, int Removed,
int Unrecognised, int Unrecognised,
int Probed,
IReadOnlyList<WalkProblem> Problems); IReadOnlyList<WalkProblem> Problems);
/// <summary>Gleicht ab, was auf der Platte liegt, mit dem, was in der Datenbank steht.</summary> /// <summary>Gleicht ab, was auf der Platte liegt, mit dem, was in der Datenbank steht.</summary>
@@ -29,7 +30,8 @@ public sealed record ScanReport(
/// merken — jene Liste wüchse mit dem Bestand. /// merken — jene Liste wüchse mit dem Bestand.
/// </para> /// </para>
/// </remarks> /// </remarks>
public sealed class LibraryScanner(HaloContext db, TimeProvider clock, ILogger<LibraryScanner> log) public sealed class LibraryScanner(
HaloContext db, IMediaProbe sonde, TimeProvider clock, ILogger<LibraryScanner> log)
{ {
public async Task<ScanReport> ScanAsync(Guid libraryId, CancellationToken ct = default) public async Task<ScanReport> ScanAsync(Guid libraryId, CancellationToken ct = default)
{ {
@@ -59,7 +61,7 @@ public sealed class LibraryScanner(HaloContext db, TimeProvider clock, ILogger<L
? await db.Episodes.Where(f => serien.Select(s => s.Id).Contains(f.SeriesId)).ToListAsync(ct) ? await db.Episodes.Where(f => 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) foreach (var datei in ergebnis.Files)
{ {
@@ -88,19 +90,30 @@ public sealed class LibraryScanner(HaloContext db, TimeProvider clock, ILogger<L
folgeId = FolgeFinden(folgen, serie.Id, name).Id; folgeId = FolgeFinden(folgen, serie.Id, name).Id;
} }
MediaFile eintrag;
bool mussUntersucht;
if (bekannteDateien.TryGetValue(datei.Path, out var bekannt)) if (bekannteDateien.TryGetValue(datei.Path, out var bekannt))
{ {
var vorher = (bekannt.SizeBytes, bekannt.ModifiedAt, bekannt.MovieId, bekannt.EpisodeId); var vorher = (bekannt.SizeBytes, bekannt.ModifiedAt, bekannt.MovieId, bekannt.EpisodeId);
// **Nur bei Änderung erneut untersuchen.** Ein Durchgang kostet
// rund fünfzig Millisekunden; bei vierzigtausend Dateien ist
// das über eine halbe Stunde, und bei jedem Lauf dieselbe.
mussUntersucht = bekannt.ProbedAt is null
|| bekannt.SizeBytes != datei.SizeBytes
|| bekannt.ModifiedAt != datei.ModifiedAt;
bekannt.SizeBytes = datei.SizeBytes; bekannt.SizeBytes = datei.SizeBytes;
bekannt.ModifiedAt = datei.ModifiedAt; bekannt.ModifiedAt = datei.ModifiedAt;
bekannt.MovieId = filmId; bekannt.MovieId = filmId;
bekannt.EpisodeId = folgeId; bekannt.EpisodeId = folgeId;
bekannt.SeenAt = laufZeit; bekannt.SeenAt = laufZeit;
if (vorher != (datei.SizeBytes, datei.ModifiedAt, filmId, folgeId)) geändert++; if (vorher != (datei.SizeBytes, datei.ModifiedAt, filmId, folgeId)) geändert++;
eintrag = bekannt;
} }
else else
{ {
db.MediaFiles.Add(new MediaFile eintrag = new MediaFile
{ {
LibraryId = libraryId, LibraryId = libraryId,
Path = datei.Path, Path = datei.Path,
@@ -109,9 +122,13 @@ public sealed class LibraryScanner(HaloContext db, TimeProvider clock, ILogger<L
SeenAt = laufZeit, SeenAt = laufZeit,
MovieId = filmId, MovieId = filmId,
EpisodeId = folgeId, EpisodeId = folgeId,
}); };
db.MediaFiles.Add(eintrag);
neu++; neu++;
mussUntersucht = true;
} }
if (mussUntersucht && await UntersuchenAsync(eintrag, laufZeit, ct)) untersucht++;
} }
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
@@ -124,10 +141,58 @@ public sealed class LibraryScanner(HaloContext db, TimeProvider clock, ILogger<L
await VerwaisteAufraeumen(libraryId, ct); await VerwaisteAufraeumen(libraryId, ct);
log.LogInformation( log.LogInformation(
"Scan von „{Name}“ fertig: {Gefunden} Dateien, {Neu} neu, {Geändert} geändert, {Entfernt} entfernt, {Unerkannt} unerkannt.", "Scan von „{Name}“ fertig: {Gefunden} Dateien, {Neu} neu, {Geändert} geändert, " +
bibliothek.Name, ergebnis.Files.Count, neu, geändert, entfernt, unerkannt); "{Entfernt} entfernt, {Unerkannt} unerkannt, {Untersucht} untersucht.",
bibliothek.Name, ergebnis.Files.Count, neu, geändert, entfernt, unerkannt, untersucht);
return new ScanReport(ergebnis.Files.Count, neu, geändert, entfernt, unerkannt, ergebnis.Problems); return new ScanReport(
ergebnis.Files.Count, neu, geändert, entfernt, unerkannt, untersucht, ergebnis.Problems);
}
/// <summary>Liest die Spuren einer Datei und schreibt sie fort.</summary>
///
/// <remarks>
/// <b>Erst speichern, dann untersuchen.</b> 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 <c>ProbedAt</c> leer und der nächste
/// Lauf versucht es erneut. Eine Datei, die gerade noch geschrieben wird,
/// ist beim nächsten Mal vielleicht lesbar.
/// </remarks>
private async Task<bool> 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;
} }
/// <summary>Löscht Titel, zu denen keine Datei mehr gehört.</summary> /// <summary>Löscht Titel, zu denen keine Datei mehr gehört.</summary>
@@ -0,0 +1,484 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid?>("ApprovedByUserId")
.HasColumnType("TEXT");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<long?>("ConsumedAt")
.HasColumnType("INTEGER");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("DeviceName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("ExpiresAt")
.HasColumnType("INTEGER");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("DeviceName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("LastSeenAt")
.HasColumnType("INTEGER");
b.Property<long?>("RevokedAt")
.HasColumnType("INTEGER");
b.Property<string>("TokenFingerprint")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Libraries");
});
modelBuilder.Entity("Halo.Core.Library.Episode", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<int>("EpisodeNumber")
.HasColumnType("INTEGER");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<int>("SeasonNumber")
.HasColumnType("INTEGER");
b.Property<Guid>("SeriesId")
.HasColumnType("TEXT");
b.Property<string>("StillImage")
.HasColumnType("TEXT");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long?>("BitRate")
.HasColumnType("INTEGER");
b.Property<string>("Container")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<long?>("Duration")
.HasColumnType("INTEGER");
b.Property<Guid?>("EpisodeId")
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<long>("ModifiedAt")
.HasColumnType("INTEGER");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<long?>("ProbedAt")
.HasColumnType("INTEGER");
b.Property<long>("SeenAt")
.HasColumnType("INTEGER");
b.Property<long>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<int?>("Channels")
.HasColumnType("INTEGER");
b.Property<string>("Codec")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int?>("Height")
.HasColumnType("INTEGER");
b.Property<int>("Index")
.HasColumnType("INTEGER");
b.Property<bool>("IsDefault")
.HasColumnType("INTEGER");
b.Property<bool>("IsForced")
.HasColumnType("INTEGER");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("Language")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<Guid>("MediaFileId")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int?>("Width")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("MediaFileId", "Index")
.IsUnique();
b.ToTable("MediaStreams");
});
modelBuilder.Entity("Halo.Core.Library.Movie", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<string>("BackdropImage")
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<string>("ParsedTitle")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PosterImage")
.HasColumnType("TEXT");
b.Property<string>("SortTitleValue")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<int?>("TmdbId")
.HasColumnType("INTEGER");
b.Property<int?>("Year")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("LibraryId", "SortTitleValue");
b.ToTable("Movies");
});
modelBuilder.Entity("Halo.Core.Library.Series", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<string>("BackdropImage")
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<string>("ParsedTitle")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PosterImage")
.HasColumnType("TEXT");
b.Property<string>("SortTitleValue")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<int?>("TmdbId")
.HasColumnType("INTEGER");
b.Property<int?>("Year")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("LibraryId", "SortTitleValue");
b.ToTable("Series");
});
modelBuilder.Entity("Halo.Core.ServerSetting", b =>
{
b.Property<string>("Key")
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("TEXT");
b.HasKey("Key");
b.ToTable("Settings");
});
modelBuilder.Entity("Halo.Core.Users.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<bool>("IsAdministrator")
.HasColumnType("INTEGER");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("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
}
}
}
@@ -0,0 +1,97 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Halo.Data.Migrations
{
/// <inheritdoc />
public partial class Spuren : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<long>(
name: "BitRate",
table: "MediaFiles",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Container",
table: "MediaFiles",
type: "TEXT",
maxLength: 32,
nullable: true);
migrationBuilder.AddColumn<long>(
name: "Duration",
table: "MediaFiles",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<long>(
name: "ProbedAt",
table: "MediaFiles",
type: "INTEGER",
nullable: true);
migrationBuilder.CreateTable(
name: "MediaStreams",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
MediaFileId = table.Column<Guid>(type: "TEXT", nullable: false),
Index = table.Column<int>(type: "INTEGER", nullable: false),
Kind = table.Column<string>(type: "TEXT", maxLength: 16, nullable: false),
Codec = table.Column<string>(type: "TEXT", maxLength: 64, nullable: true),
Language = table.Column<string>(type: "TEXT", maxLength: 16, nullable: true),
Title = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
IsDefault = table.Column<bool>(type: "INTEGER", nullable: false),
IsForced = table.Column<bool>(type: "INTEGER", nullable: false),
Channels = table.Column<int>(type: "INTEGER", nullable: true),
Width = table.Column<int>(type: "INTEGER", nullable: true),
Height = table.Column<int>(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);
}
/// <inheritdoc />
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");
}
}
}
@@ -179,6 +179,16 @@ namespace Halo.Data.Migrations
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<long?>("BitRate")
.HasColumnType("INTEGER");
b.Property<string>("Container")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<long?>("Duration")
.HasColumnType("INTEGER");
b.Property<Guid?>("EpisodeId") b.Property<Guid?>("EpisodeId")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@@ -196,6 +206,9 @@ namespace Halo.Data.Migrations
.HasMaxLength(1024) .HasMaxLength(1024)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<long?>("ProbedAt")
.HasColumnType("INTEGER");
b.Property<long>("SeenAt") b.Property<long>("SeenAt")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
@@ -214,6 +227,58 @@ namespace Halo.Data.Migrations
b.ToTable("MediaFiles"); b.ToTable("MediaFiles");
}); });
modelBuilder.Entity("Halo.Core.Library.MediaStream", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<int?>("Channels")
.HasColumnType("INTEGER");
b.Property<string>("Codec")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int?>("Height")
.HasColumnType("INTEGER");
b.Property<int>("Index")
.HasColumnType("INTEGER");
b.Property<bool>("IsDefault")
.HasColumnType("INTEGER");
b.Property<bool>("IsForced")
.HasColumnType("INTEGER");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("Language")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<Guid>("MediaFileId")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int?>("Width")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("MediaFileId", "Index")
.IsUnique();
b.ToTable("MediaStreams");
});
modelBuilder.Entity("Halo.Core.Library.Movie", b => modelBuilder.Entity("Halo.Core.Library.Movie", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -384,6 +449,15 @@ namespace Halo.Data.Migrations
.IsRequired(); .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 => modelBuilder.Entity("Halo.Core.Library.Movie", b =>
{ {
b.HasOne("Halo.Core.Libraries.Library", null) b.HasOne("Halo.Core.Libraries.Library", null)
+25
View File
@@ -0,0 +1,25 @@
namespace Halo.Media;
/// <summary>Wo ffmpeg liegt und wie lange es brauchen darf.</summary>
///
/// <remarks>
/// <b>Die Pfade sind einstellbar</b>, weil sie nicht überall gleich sind: im
/// Container liegt ffmpeg unter <c>/usr/bin</c>, auf einem Mac mit Homebrew
/// unter <c>/opt/homebrew/bin</c>, und manche bringen ihren eigenen Bau mit.
/// Ein fester Pfad hier wäre eine Annahme über ein fremdes System.
/// </remarks>
public sealed record FfmpegOptions(string Ffmpeg = "ffmpeg", string Ffprobe = "ffprobe")
{
/// <summary>Nach dieser Zeit gilt eine Datei als nicht lesbar.</summary>
///
/// <remarks>
/// 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.
/// </remarks>
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");
}
+133
View File
@@ -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);
/// <summary>Packt eine Datei in eine MP4 um — mit allen Spuren.</summary>
///
/// <remarks>
/// <para>
/// <b>Das ist der Grund, warum es diesen Server gibt.</b> 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.
/// </para>
/// <para>
/// <b>Keine Zeitgrenze.</b> 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.
/// </para>
/// </remarks>
public sealed class FfmpegRemuxer(FfmpegOptions optionen, ILogger<FfmpegRemuxer> log)
{
public async Task<RemuxOutcome> RunAsync(
RemuxRequest anfrage,
TimeSpan? gesamtdauer = null,
IProgress<double>? 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);
}
}
/// <summary>Liest die Fortschrittszeilen von ffmpeg mit.</summary>
///
/// <remarks>
/// ffmpeg schreibt dort Zeilen der Form <c>out_time_us=12345678</c>.
/// Gelesen wird laufend — nicht am Ende: ein Fortschritt, der erst
/// vorliegt, wenn alles fertig ist, hilft niemandem.
/// </remarks>
private static async Task FortschrittLesen(
Process prozess, TimeSpan? gesamt, IProgress<double>? 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));
}
}
}
/// <summary>Eine halbe Datei ist schlimmer als keine.</summary>
///
/// <remarks>
/// 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.
/// </remarks>
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); }
}
}
+201
View File
@@ -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;
/// <summary>Liest die technischen Angaben einer Datei mit ffprobe.</summary>
///
/// <remarks>
/// <para>
/// <b>Als eigener Prozess, nicht über Bindings.</b> 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.
/// </para>
/// <para>
/// <b>Die Argumente gehen als Liste, nicht als Zeile.</b> Ein Dateiname wie
/// <c>Der "echte" Film.mkv</c> oder einer mit einem Apostroph zerlegte eine
/// zusammengesetzte Kommandozeile — und was dann ausgeführt würde, bestimmte
/// der Dateiname. Mit <see cref="ProcessStartInfo.ArgumentList"/> gibt es
/// diese Frage gar nicht erst.
/// </para>
/// </remarks>
public sealed class FfprobeRunner(FfmpegOptions optionen, ILogger<FfprobeRunner> log) : IMediaProbe
{
public async Task<ProbeResult?> 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<ProbedStream>();
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;
}
}
/// <summary>ffprobe nennt mehrere Namen auf einmal — hier zählt der erste.</summary>
///
/// <remarks>
/// Eine MP4 meldet sich als <c>mov,mp4,m4a,3gp,3g2,mj2</c>, 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.
/// </remarks>
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,
};
}
/// <summary>Der Name der Spur — das Feld heisst je nach Schachtel anders.</summary>
///
/// <remarks>
/// <b>Matroska nennt es <c>title</c>, MP4 nennt es <c>name</c>.</b> Beim
/// Umpacken schreibt ffmpeg das eine ins andere um, und wer nur
/// <c>title</c> liest, hält jede umgepackte Datei für namenlos. Genau das
/// ist beim ersten Versuch passiert: nach dem Umpacken waren „Deutsch"
/// und „Audiodeskription" beide <c>null</c> — und damit zwei deutsche
/// Tonspuren nicht mehr auseinanderzuhalten.
///
/// <c>handler_name</c> steht ausdrücklich nicht dabei: dort schreibt
/// ffmpeg „SoundHandler" hinein, und das ist kein Name, sondern eine
/// Formalie.
/// </remarks>
private static string? Spurname(JsonElement kennzeichen) =>
Text(kennzeichen, "title") ?? Text(kennzeichen, "name");
/// <summary>„und" heisst unbekannt und ist dasselbe wie keine Angabe.</summary>
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;
}
+17
View File
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\Halo.Core\Halo.Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.12" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
+103
View File
@@ -0,0 +1,103 @@
using Halo.Core.Delivery;
using Halo.Core.Library;
namespace Halo.Media;
public sealed record RemuxRequest(
string SourcePath,
string TargetPath,
IReadOnlyList<ProbedStream> Streams,
DeliveryPlan Plan);
/// <summary>Baut die ffmpeg-Zeile fürs Umpacken.</summary>
///
/// <remarks>
/// <para>
/// <b>Eine reine Funktion, damit sie geprüft werden kann, ohne etwas
/// auszuführen.</b> 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.
/// </para>
/// <para>
/// <b>Jede Spur wird einzeln benannt</b>, statt mit <c>-map 0</c> 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.
/// </para>
/// </remarks>
public static class RemuxArguments
{
/// <summary>Was eine Tonspur je Kanal bekommt, wenn sie neu gerechnet wird.</summary>
private const int BitrateJeKanal = 96_000;
private const int HöchsteBitrate = 640_000;
public static List<string> 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<string>
{
"-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);
}
@@ -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;
/// <summary>Der Weg vom Scan bis zur heruntergeladenen Datei.</summary>
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) { }
}
/// <summary>Eine Datei mit zwei Tonspuren, in der gewünschten Schachtel.</summary>
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<LibraryResponse>())!;
(await client.PostAsync($"/api/v1/libraries/{bibliothek.Id}/scan", null)).EnsureSuccessStatusCode();
for (var i = 0; i < 150; i++)
{
var stand = await client.GetFromJsonAsync<ScanStatus>($"/api/v1/libraries/{bibliothek.Id}/scan");
if (stand!.State == "finished") break;
await Task.Delay(50);
}
var filme = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies");
Assert.That(filme!.Items, Is.Not.Empty, "Der Scan hat nichts gefunden");
return (client, filme.Items[0].Files.Single());
}
private HaloServer? _server;
[TearDown]
public void ServerSchliessen() { _server?.Dispose(); _server = null; }
/// <summary>Der Scan trägt die Spuren ein — darunter die beiden Namen.</summary>
[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" }));
}
/// <summary>Der volle Pfad des Servers geht niemanden etwas an.</summary>
[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<DeliveryInfo>($"/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");
}
/// <summary>Eine Matroska wird umgepackt — und der Client erfährt es vorher.</summary>
[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<DeliveryInfo>($"/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");
}
/// <summary>Fortsetzen nach einem Abbruch — die Forderung aus dem Fehlerbericht.</summary>
[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));
}
}
@@ -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);
}
/// <summary>Der Fall, um den es im ganzen Projekt geht.</summary>
///
/// <remarks>
/// <b>Zwei Tonspuren, und beide kommen mit.</b> 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.
/// </remarks>
[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);
}
/// <summary>DTS kennt AVPlayer nicht — nur diese eine Spur wird gerechnet.</summary>
[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);
}
/// <summary>Betrifft fast jeden Blu-ray-Rip.</summary>
///
/// <remarks>
/// 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.
/// </remarks>
[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));
}
/// <summary>Was die Datei nicht nennt, gilt als passend.</summary>
///
/// <remarks>
/// Vorsichtshalber umzupacken hiesse, aus einer tadellosen MP4 eine neue
/// Datei zu machen — für eine Angabe, die schlicht fehlt.
/// </remarks>
[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<string> { "mkv" }, new HashSet<string> { "h264" }, new HashSet<string> { "dts" });
var plan = DeliveryDecider.ForDownload("mkv", [Bild(), Ton(1, "dts")], alleskoenner);
Assert.That(plan.Method, Is.EqualTo(DeliveryMethod.DirectFile));
}
}
@@ -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<FfprobeRunner>.Instance);
}
[TearDown]
public void Aufraeumen()
{
try { if (_ordner.Length > 0) Directory.Delete(_ordner, recursive: true); } catch (IOException) { }
}
/// <summary>Die Frage, um die es im ganzen Projekt geht.</summary>
///
/// <remarks>
/// <b>Zwei Tonspuren, beide deutsch, eine davon Audiodeskription.</b> 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.
/// </remarks>
[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));
}
/// <summary>Die Nummern müssen die von ffmpeg sein, nicht eine eigene Zählung.</summary>
///
/// <remarks>
/// An ihnen hängt später das Umpacken: <c>-map 0:2</c> spricht die dritte
/// Spur an. Eine eigene Zählung je Art wäre dort eine Falle.
/// </remarks>
[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)));
}
/// <summary>Die MP4-Familie meldet sechs Namen auf einmal.</summary>
[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);
}
/// <summary>Ein Dateiname mit Anführungszeichen darf nichts ausführen.</summary>
///
/// <remarks>
/// Die Argumente gehen als Liste an den Prozess, nicht als zusammengesetzte
/// Zeile. Sonst bestimmte der Dateiname mit, was ausgeführt wird.
/// </remarks>
[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<FfprobeRunner>.Instance);
var datei = Path.Combine(_ordner, "egal.mp4");
Testdateien.Einfach(datei);
Assert.That(await ohne.ProbeAsync(datei), Is.Null);
}
}
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="NUnit" Version="4.3.2" />
<PackageReference Include="NUnit.Analyzers" Version="4.7.0" />
<PackageReference Include="NUnit3TestAdapter" Version="5.0.0" />
</ItemGroup>
<ItemGroup>
<Using Include="NUnit.Framework" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Halo.Media\Halo.Media.csproj" />
<ProjectReference Include="..\..\src\Halo.Core\Halo.Core.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,121 @@
using Halo.Core.Delivery;
using Halo.Core.Library;
namespace Halo.Media.Tests;
/// <summary>Die ffmpeg-Zeile, betrachtet ohne sie auszuführen.</summary>
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<string> Bauen(IReadOnlyList<ProbedStream> 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<string> argumente) => string.Join(' ', argumente);
/// <summary>Jede Spur wird einzeln benannt — und zwar alle.</summary>
[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"));
}
/// <summary>Die Verwechslung, vor der der Kommentar an MediaStream.Index warnt.</summary>
///
/// <remarks>
/// <b><c>-c:a:1</c> meint die zweite Tonspur der Ausgabe, nicht die Spur
/// Nummer 1 der Datei.</b> Hier steht DTS an Dateinummer 2 und ist die
/// zweite Tonspur — also <c>-c:a:1</c>. Wer die Dateinummer einsetzte,
/// träfe eine andere Spur oder gar keine.
/// </remarks>
[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");
}
/// <summary>Fällt ein Untertitel weg, verschieben sich die Tonnummern nicht.</summary>
///
/// <remarks>
/// 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.
/// </remarks>
[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");
}
}
+188
View File
@@ -0,0 +1,188 @@
using Halo.Core.Delivery;
using Halo.Core.Library;
using Microsoft.Extensions.Logging.Abstractions;
namespace Halo.Media.Tests;
/// <summary>Umpacken, von der echten Datei bis zum Ergebnis.</summary>
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<FfprobeRunner>.Instance);
_umpacker = new FfmpegRemuxer(optionen, NullLogger<FfmpegRemuxer>.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<double>? 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);
}
/// <summary>Der Beweis, auf dem das ganze Projekt ruht.</summary>
///
/// <remarks>
/// <b>Zwei Tonspuren und ein Untertitel gehen hinein, und alles kommt in
/// der MP4 wieder heraus</b> — 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.
/// </remarks>
[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"));
}
/// <summary>Nur die untaugliche Spur wird gerechnet, die andere kopiert.</summary>
[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");
}
/// <summary>Das Bild wird nie angefasst — nachgewiesen an der Bildgrösse und am Codec.</summary>
[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<double>();
var (ergebnis, _) = await Umpacken(quelle, new Progress<double>(w => { lock (gemeldet) gemeldet.Add(w); }));
Assert.That(ergebnis.Success, Is.True);
// Progress<T> 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");
}
}
/// <summary>Eine halbe Datei ist schlimmer als keine.</summary>
///
/// <remarks>
/// Bliebe sie liegen, hielte der nächste Aufruf sie für fertig und gäbe
/// einen abgeschnittenen Film heraus — der beim Abspielen mittendrin
/// abbricht.
/// </remarks>
[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<OperationCanceledException>(() => _umpacker.RunAsync(
new RemuxRequest(quelle, ziel, davor!.Streams,
DeliveryDecider.ForDownload(davor.Container, davor.Streams)),
ct: abbruch.Token));
Assert.That(File.Exists(ziel), Is.False);
}
}
@@ -0,0 +1,97 @@
using System.Diagnostics;
namespace Halo.Media.Tests;
/// <summary>Baut echte Mediendateien mit ffmpeg.</summary>
///
/// <remarks>
/// <b>Echte Dateien und keine aufgezeichneten Antworten.</b> 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.
/// </remarks>
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;
}
/// <summary>Zwei Tonspuren mit Sprache und Namen, dazu ein Untertitel.</summary>
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);
}
/// <summary>Eine schlichte MP4 mit einer Tonspur.</summary>
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,
]);
/// <summary>Eine MKV mit einer Tonspur in einem Codec, den Apple nicht kennt.</summary>
///
/// <remarks>
/// <b>DTS, also der echte Fall.</b> 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
/// <c>-strict -2</c> — das betrifft nur das Erzeugen dieser Testvorlage,
/// nicht Halo.
/// </remarks>
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}");
}
}