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>
112 lines
4.7 KiB
C#
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..];
|
|
}
|