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..];
}