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
+35 -1
View File
@@ -45,6 +45,7 @@ public sealed record ScanStatus(
int? Updated,
int? Removed,
int? Unrecognised,
int? Probed,
IReadOnlyList<ScanProblem>? Problems,
string? Error,
MetadataProgress? Metadata);
@@ -53,7 +54,22 @@ public sealed record ScanStatus(
public sealed record MetadataProgress(
int MoviesMatched, int SeriesMatched, int EpisodesMatched, bool Skipped, string? Error);
public sealed record MediaFileResponse(Guid Id, string Path, long SizeBytes);
/// <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(
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 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);
}
/// <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)
{
app.MapPost("/api/v1/libraries/{id:guid}/scan", async (
@@ -38,13 +63,14 @@ public static class LibraryContentEndpoints
{
var zustand = koordinator.State(id);
if (zustand is null)
return Results.Ok(new ScanStatus("idle", null, null, null, null, null, null, null, null, null, null));
return Results.Ok(new ScanStatus("idle", null, null, null, null, null, null, null, null, null, null, null));
var bericht = zustand.Report;
var metadaten = zustand.Enrichment;
return Results.Ok(new ScanStatus(
zustand.Status, zustand.StartedAt, zustand.FinishedAt,
bericht?.FilesFound, bericht?.Added, bericht?.Updated, bericht?.Removed, bericht?.Unrecognised,
bericht?.Probed,
bericht?.Problems.Select(p => new ScanProblem(p.Path, p.Reason)).ToList(),
zustand.Error,
metadaten is null ? null : new MetadataProgress(
@@ -76,13 +102,14 @@ public static class LibraryContentEndpoints
var ids = kennungen.Select(m => m.Id).ToList();
var dateien = await db.MediaFiles.AsNoTracking()
.Where(f => f.MovieId != null && ids.Contains(f.MovieId!.Value))
.Select(f => new { f.Id, f.Path, f.SizeBytes, f.MovieId })
.Select(f => new { f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, f.MovieId })
.ToListAsync();
var spuren = await SpurenZu(db, dateien.Select(f => f.Id).ToList());
var items = kennungen.Select(m => new MovieResponse(
m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage,
dateien.Where(f => f.MovieId == m.Id)
.Select(f => new MediaFileResponse(f.Id, f.Path, f.SizeBytes)).ToList())).ToList();
.Select(f => Datei(f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, spuren)).ToList())).ToList();
return Results.Ok(new Page<MovieResponse>(items, gesamt));
}).RequireAuthorization();
@@ -134,13 +161,14 @@ public static class LibraryContentEndpoints
var ids = folgen.Select(f => f.Id).ToList();
var dateien = await db.MediaFiles.AsNoTracking()
.Where(d => d.EpisodeId != null && ids.Contains(d.EpisodeId!.Value))
.Select(d => new { d.Id, d.Path, d.SizeBytes, d.EpisodeId })
.Select(d => new { d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, d.EpisodeId })
.ToListAsync();
var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList());
var items = folgen.Select(f => new EpisodeResponse(
f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview, f.StillImage,
dateien.Where(d => d.EpisodeId == f.Id)
.Select(d => new MediaFileResponse(d.Id, d.Path, d.SizeBytes)).ToList())).ToList();
.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren)).ToList())).ToList();
return Results.Ok(new Page<EpisodeResponse>(items, items.Count));
}).RequireAuthorization();
+1
View File
@@ -4,6 +4,7 @@
<ProjectReference Include="..\Halo.Core\Halo.Core.csproj" />
<ProjectReference Include="..\Halo.Data\Halo.Data.csproj" />
<ProjectReference Include="..\Halo.Metadata\Halo.Metadata.csproj" />
<ProjectReference Include="..\Halo.Media\Halo.Media.csproj" />
</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.Core.Metadata;
using Halo.Data;
using Halo.Core.Library;
using Halo.Media;
using Halo.Metadata;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.RateLimiting;
@@ -59,6 +61,12 @@ builder.Services.AddRateLimiter(o =>
_ => new FixedWindowRateLimiterOptions { PermitLimit = beimNachfragen, Window = TimeSpan.FromMinutes(1) }));
});
// ffmpeg als eigener Prozess, nicht eingebunden: hält die Lizenzen getrennt
// und lässt einen Absturz dort den Server nicht mitnehmen.
builder.Services.AddSingleton(FfmpegOptions.FromEnvironment());
builder.Services.AddSingleton<IMediaProbe, FfprobeRunner>();
builder.Services.AddSingleton<FfmpegRemuxer>();
builder.Services.AddScoped<LibraryScanner>();
// 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<MetadataEnricher>();
builder.Services.AddSingleton<ScanCoordinator>();
builder.Services.AddSingleton<PackageCoordinator>();
builder.Services.AddProblemDetails();
var app = builder.Build();
@@ -99,6 +108,7 @@ app.MapManagement();
app.MapLibraryContent();
app.MapSettings();
app.MapImages();
app.MapDelivery();
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? 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
@@ -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?>(
zeitpunkt => zeitpunkt == null ? null : zeitpunkt.Value.ToUnixTimeMilliseconds(),
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<Series> Series => Set<Series>();
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>
///
@@ -43,6 +44,8 @@ public sealed class HaloContext(DbContextOptions<HaloContext> options) : DbConte
{
builder.Properties<DateTimeOffset>().HaveConversion<DateTimeOffsetToUnixMilliseconds>();
builder.Properties<DateTimeOffset?>().HaveConversion<NullableDateTimeOffsetToUnixMilliseconds>();
builder.Properties<TimeSpan>().HaveConversion<TimeSpanToMilliseconds>();
builder.Properties<TimeSpan?>().HaveConversion<NullableTimeSpanToMilliseconds>();
}
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.EpisodeId);
e.Property(x => x.Path).HasMaxLength(1024);
e.Property(x => x.Container).HasMaxLength(32);
e.HasOne<Core.Libraries.Library>().WithMany().HasForeignKey(x => x.LibraryId)
.OnDelete(DeleteBehavior.Cascade);
});
@@ -131,6 +135,19 @@ public sealed class HaloContext(DbContextOptions<HaloContext> options) : DbConte
.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 =>
{
e.HasKey(x => x.Id);
+72 -7
View File
@@ -11,6 +11,7 @@ public sealed record ScanReport(
int Updated,
int Removed,
int Unrecognised,
int Probed,
IReadOnlyList<WalkProblem> Problems);
/// <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.
/// </para>
/// </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)
{
@@ -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)
: [];
int neu = 0, geändert = 0, unerkannt = 0;
int neu = 0, geändert = 0, unerkannt = 0, untersucht = 0;
foreach (var datei in ergebnis.Files)
{
@@ -88,19 +90,30 @@ public sealed class LibraryScanner(HaloContext db, TimeProvider clock, ILogger<L
folgeId = FolgeFinden(folgen, serie.Id, name).Id;
}
MediaFile eintrag;
bool mussUntersucht;
if (bekannteDateien.TryGetValue(datei.Path, out var bekannt))
{
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.ModifiedAt = datei.ModifiedAt;
bekannt.MovieId = filmId;
bekannt.EpisodeId = folgeId;
bekannt.SeenAt = laufZeit;
if (vorher != (datei.SizeBytes, datei.ModifiedAt, filmId, folgeId)) geändert++;
eintrag = bekannt;
}
else
{
db.MediaFiles.Add(new MediaFile
eintrag = new MediaFile
{
LibraryId = libraryId,
Path = datei.Path,
@@ -109,9 +122,13 @@ public sealed class LibraryScanner(HaloContext db, TimeProvider clock, ILogger<L
SeenAt = laufZeit,
MovieId = filmId,
EpisodeId = folgeId,
});
};
db.MediaFiles.Add(eintrag);
neu++;
mussUntersucht = true;
}
if (mussUntersucht && await UntersuchenAsync(eintrag, laufZeit, ct)) untersucht++;
}
await db.SaveChangesAsync(ct);
@@ -124,10 +141,58 @@ public sealed class LibraryScanner(HaloContext db, TimeProvider clock, ILogger<L
await VerwaisteAufraeumen(libraryId, ct);
log.LogInformation(
"Scan von „{Name}“ fertig: {Gefunden} Dateien, {Neu} neu, {Geändert} geändert, {Entfernt} entfernt, {Unerkannt} unerkannt.",
bibliothek.Name, ergebnis.Files.Count, neu, geändert, entfernt, unerkannt);
"Scan von „{Name}“ fertig: {Gefunden} Dateien, {Neu} neu, {Geändert} geändert, " +
"{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>
@@ -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()
.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");
@@ -196,6 +206,9 @@ namespace Halo.Data.Migrations
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<long?>("ProbedAt")
.HasColumnType("INTEGER");
b.Property<long>("SeenAt")
.HasColumnType("INTEGER");
@@ -214,6 +227,58 @@ namespace Halo.Data.Migrations
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")
@@ -384,6 +449,15 @@ namespace Halo.Data.Migrations
.IsRequired();
});
modelBuilder.Entity("Halo.Core.Library.MediaStream", b =>
{
b.HasOne("Halo.Core.Library.MediaFile", null)
.WithMany()
.HasForeignKey("MediaFileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Halo.Core.Library.Movie", b =>
{
b.HasOne("Halo.Core.Libraries.Library", null)
+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);
}