Files
Flaro/server/src/Flaro.Api/Endpoints/DeliveryEndpoints.cs
T
ScarriffleandClaude Opus 5 67cf0fd1d9 HEAD auf den Download, und /api verschluckt nichts mehr
Zwei Fehler, derselbe Grund — und beide zeigte erst der Browser.

**Der Spieler bekam die Webseite statt des Films.** „MEDIA_ELEMENT_ERROR:
Format error", 531 Byte, `Content-Type: text/html`. Das ist genau die Grösse
von `index.html`.

`MapGet` nimmt HEAD nicht an. Die Oberfläche fragt aber mit HEAD nach, ob das
Umpacken fertig ist — einen halbfertigen Film herunterzuladen, nur um das zu
erfahren, wäre absurd. Die Anfrage fiel durch den Methodenabgleich hindurch
bis zum SPA-Rückfall, und der nimmt jeden Pfad ohne Dateiendung. Antwort:
200, mit der Startseite im Rumpf. Der Client hielt das Umpacken für fertig
und reichte HTML an `<video>` weiter.

Jetzt `MapMethods` mit GET und HEAD. Kestrel lässt den Rumpf bei HEAD selbst
weg.

**Und ein vertippter Aufruf sah aus wie ein Erfolg.** `GET /api/v1/gibtsnicht`
gab 200 und die Webseite zurück — derselbe Rückfall. Ein Fehler, der erst
dort auffällt, wo jemand HTML durch einen JSON-Parser schickt.

`app.Map("/api/{*rest}", 404)` davor. Buchstaben schlagen Platzhalter, also
gewinnt jeder echte Endpunkt; was übrig bleibt, ist nicht da und sagt das
auch.

350 Tests grün, die zwei neuen eingerechnet. Am Container nachgemessen: HEAD
auf den Download gibt 200 `video/mp4` mit 71 920 Byte, `/api/v1/gibtsnicht`
gibt 404, und ein Neuladen auf `/verwaltung` bekommt weiterhin die Seite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 21:33:56 +02:00

112 lines
4.7 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) =>
{
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(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..];
}