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(); } /// Ab wann diese Datei herausgehen darf — null heisst: sofort. /// /// /// /// Hier und nicht im Sichtbarkeitsfilter. 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. /// /// /// Für alle, auch für Administratoren. 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. /// /// internal static async Task 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; } /// Der Satz, der statt der Datei kommt. 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..]; }