Die Auflösung wurde allein aus der Höhe gebildet. Ein Kinoformat von 2.39:1 liegt als 1920×800 vor — nach der Höhe gerechnet kam „720p" heraus, und das stand so in der Oberfläche. Jetzt zählt die grössere von Breite und Höhe; Hochformat kippt damit nicht, und 4K-Breitbild heisst nicht mehr 1440p. Die Detailansicht hat zwei Leserschaften, und bisher bekamen beide dasselbe. Wer schauen will, sieht jetzt Laufzeit, Auflösung und die Sprachen — jede einmal, statt „Englisch · Englisch · Englisch · Englisch · Deutsch". Wer die Mediathek betreut, sieht darunter Dateiformat, Grösse und den Ordner. Der Ordner verrät eine falsche Zuordnung in einer Sekunde: steht über der Beschreibung „Hanni & Nanni 3" und der Ordner heisst „Hanni und Nanni", ist die Sache klar. Er kommt nur für Administratoren überhaupt vom Server — versteckt im Client wäre er weiterhin auslesbar. Ein geplanter Titel war unsichtbar. Das ist die Hälfte, die nichts bringt: dass er kommt, ist der Sinn des Planens. Er steht jetzt mit „Verfügbar ab …" in der Mediathek, und die Datei geht trotzdem nicht heraus — Download wie Strom lehnen bis zum Termin ab, auch für Administratoren. Wer vorziehen will, hat „Jetzt veröffentlichen"; eine Hintertür für eine Rolle wäre eine Regel, die manchmal gilt. Was ein Auffrischen geändert hat, steht jetzt unter „Zu prüfen" — in einer eigenen Gruppe mit „Zur Kenntnis" statt „Veröffentlichen". Diese Titel sind sichtbar; es gibt nichts freizugeben, nur etwas abzuhaken. Und das Protokoll sagt endlich, wo es knallt. „InvalidOperationException: Nullable object must have a value." ohne eine einzige Zeile Herkunft ist für niemanden eine Diagnose — weder für den Betreiber noch für mich. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
149 lines
6.4 KiB
C#
149 lines
6.4 KiB
C#
using Flaro.Core.Delivery;
|
|
using Flaro.Core.Library;
|
|
using Flaro.Data;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace Flaro.Api.Endpoints;
|
|
|
|
public static class DeliveryEndpoints
|
|
{
|
|
public static void MapDelivery(this IEndpointRouteBuilder app)
|
|
{
|
|
app.MapGet("/api/v1/files/{id:guid}/delivery", async (
|
|
Guid id, FlaroContext 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();
|
|
|
|
// **GET und HEAD.** Der Client fragt mit HEAD nach, ob das Umpacken
|
|
// fertig ist — eine Antwort ohne Rumpf genügt dafür, und einen
|
|
// halbfertigen Film herunterzuladen, nur um das zu erfahren, wäre
|
|
// absurd. `MapGet` allein nimmt HEAD nicht an; die Anfrage fiele dann
|
|
// durch bis zum SPA-Rückfall und bekäme die Webseite mit Status 200.
|
|
// Genau das ist passiert: der Spieler bekam HTML statt Video.
|
|
app.MapMethods("/api/v1/files/{id:guid}/download", ["GET", "HEAD"], async (
|
|
Guid id, FlaroContext db, PackageCoordinator packer, TimeProvider uhr) =>
|
|
{
|
|
var gefunden = await Laden(db, id);
|
|
if (gefunden is not var (datei, plan)) return Results.NotFound();
|
|
|
|
if (await FreiAb(db, datei, uhr.GetUtcNow()) is { } nochNicht) return NochNicht(nochNicht);
|
|
|
|
// 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();
|
|
}
|
|
|
|
/// <summary>Ab wann diese Datei herausgehen darf — <c>null</c> heisst: sofort.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Hier und nicht im Sichtbarkeitsfilter.</b> Ein geplanter Titel steht
|
|
/// mit „Verfügbar ab …" in der Mediathek — das ist der Sinn des Planens.
|
|
/// Sichtbar heisst aber nicht abspielbar: ohne diese Prüfung liesse sich
|
|
/// die Datei trotzdem herunterladen, und die Angabe wäre eine Zierde.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Für alle, auch für Administratoren.</b> Wer die Freigabe vorziehen
|
|
/// will, hat dafür den Knopf „Jetzt veröffentlichen" — eine Hintertür für
|
|
/// eine Rolle wäre eine Regel, die manchmal gilt.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal static async Task<DateTimeOffset?> FreiAb(
|
|
FlaroContext db, MediaFile datei, DateTimeOffset jetzt)
|
|
{
|
|
DateTimeOffset? wann = datei.MovieId is { } film
|
|
? await db.Movies.AsNoTracking().Where(m => m.Id == film)
|
|
.Select(m => m.PublishAt).FirstOrDefaultAsync()
|
|
: datei.EpisodeId is { } folge
|
|
? await db.Episodes.AsNoTracking().Where(f => f.Id == folge)
|
|
.Select(f => f.PublishAt).FirstOrDefaultAsync()
|
|
: null;
|
|
|
|
return wann is { } termin && termin > jetzt ? termin : null;
|
|
}
|
|
|
|
/// <summary>Der Satz, der statt der Datei kommt.</summary>
|
|
internal static IResult NochNicht(DateTimeOffset wann) =>
|
|
Results.Problem(
|
|
$"Dieser Titel ist ab {wann.ToLocalTime():dd.MM.yyyy, HH:mm} Uhr verfügbar.",
|
|
statusCode: StatusCodes.Status403Forbidden);
|
|
|
|
private static async Task<(MediaFile Datei, DeliveryPlan Plan)?> Laden(FlaroContext 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..];
|
|
}
|