Spider-Man ist nicht 720p, er ist breit

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>
This commit is contained in:
Scarriffle
2026-10-06 14:51:00 +02:00
co-authored by Claude Opus 5
parent f44779c899
commit 255eb7c2f0
19 changed files with 1569 additions and 64 deletions
+42 -3
View File
@@ -194,7 +194,19 @@ public sealed record Pruefeintrag(
/// <summary>Wie viele Titel dahinterstecken — bei einer Serie die Zahl der Folgen.</summary>
int Count,
/// <summary>Worauf eine Freigabe wirkt.</summary>
IReadOnlyList<Guid> Ids);
IReadOnlyList<Guid> Ids,
/// <summary>Warum der Eintrag hier steht: <c>neu</c> oder <c>geaendert</c>.</summary>
///
/// <remarks>
/// <b>Zwei verschiedene Dinge in einer Liste, und das mit Absicht.</b> Ein
/// neuer Fund wartet auf Freigabe und ist bis dahin unsichtbar. Ein
/// geänderter Titel steht längst in der Mediathek — er soll nur nicht
/// unbemerkt anders heissen oder ein anderes Plakat tragen. Beides gehört
/// vor dieselben Augen, aber nicht unter denselben Knopf: das eine wird
/// veröffentlicht, das andere nur zur Kenntnis genommen.
/// </remarks>
string Grund = "neu");
public sealed record FreigabeRequest(
IReadOnlyList<Guid>? Ids,
@@ -256,7 +268,17 @@ public sealed record MediaStreamResponse(
/// dieselbe Tonspur am Fernseher anders als im Browser. Die Rohwerte
/// stehen weiterhin daneben; wer sie braucht, hat sie.
/// </remarks>
string? Display = null);
string? Display = null,
/// <summary>Die Sprache als Wort — „Deutsch" statt „deu".</summary>
///
/// <remarks>
/// <b>Damit eine Liste von Sprachen ohne Rätselraten entsteht.</b> Die
/// Detailansicht nennt, in welchen Sprachen ein Film vorliegt; sie dafür
/// aus <see cref="Display"/> herauszuschneiden hiesse, sich auf dessen
/// Satzbau zu verlassen.
/// </remarks>
string? LanguageName = null);
/// <summary>Eine Datei hinter einem Titel — eine Fassung also.</summary>
///
@@ -272,9 +294,26 @@ public sealed record MediaStreamResponse(
/// derselben Folge als zwei gleich aussehende Knopfreihen untereinander, und
/// die Folge wirkte doppelt.
/// </param>
/// <param name="Folder">
/// <b>Der Ordner unterhalb der Mediathek</b> — <c>null</c> für alle, die nicht
/// verwalten dürfen.
///
/// <para>
/// <b>Weil man einer falschen Zuordnung am Ordner sofort ansieht, dass sie
/// falsch ist.</b> Steht über der Beschreibung „Hanni &amp; Nanni 3" und der
/// Ordner heisst <c>Hanni und Nanni</c>, ist die Sache in einer Sekunde klar
/// — ohne Umweg über den Zuordnungsdialog.
/// </para>
/// <para>
/// <b>Nur der Teil unterhalb der Mediathek, und nur für Administratoren.</b>
/// Der volle Pfad verriete jedem Angemeldeten den Aufbau des Servers; der
/// relative sagt genau das, was zum Beurteilen nötig ist.
/// </para>
/// </param>
public sealed record MediaFileResponse(
Guid Id, string FileName, long SizeBytes, string? Container, double? DurationSeconds,
IReadOnlyList<MediaStreamResponse> Streams, string? VersionLabel = null);
IReadOnlyList<MediaStreamResponse> Streams, string? VersionLabel = null,
string? Folder = null);
public sealed record MovieResponse(
Guid Id, string Title, int? Year, string? Overview,
@@ -25,11 +25,13 @@ public static class DeliveryEndpoints
// 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) =>
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.
@@ -70,6 +72,41 @@ public static class DeliveryEndpoints
}).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);
@@ -21,12 +21,16 @@ public static class FreigabeEndpoints
var jetzt = uhr.GetUtcNow();
var filme = await db.Movies.AsNoTracking()
.Where(m => m.PublishAt == null || m.PublishAt > jetzt)
.Where(m => m.PublishAt == null || m.PublishAt > jetzt || m.ChangedSince != null)
.OrderBy(m => m.SortTitleValue)
.Select(m => new Pruefeintrag(
"movie", m.Id, m.Title, m.Year, m.PosterImage, m.PublishAt,
db.MediaFiles.Where(f => f.MovieId == m.Id).Select(f => f.Path).FirstOrDefault(),
m.TmdbId != null, 1, new List<Guid> { m.Id }))
m.TmdbId != null, 1, new List<Guid> { m.Id },
// **Neu sticht geändert.** Ein Titel, der noch gar nicht
// sichtbar ist, braucht eine Freigabe — dass sich dabei
// auch etwas geändert hat, ist die kleinere Nachricht.
m.PublishAt == null || m.PublishAt > jetzt ? "neu" : "geaendert"))
.ToListAsync(ct);
// **Folgen werden nach Serie gebündelt.** Zwanzig neue Folgen
@@ -71,13 +75,54 @@ public static class FreigabeEndpoints
.OrderBy(e => e.Title)
.ToList();
return Results.Ok(filme.Concat(gebuendelt).ToList());
// **Geänderte Serien stehen für sich.** Die Bündelung oben geht
// über wartende Folgen; eine Serie, deren Titel oder Plakat sich
// geändert hat, hat damit nichts zu tun — ihre Folgen sind längst
// sichtbar.
var schonDa = gebuendelt.Select(e => e.Id).ToHashSet();
var geaenderteSerien = await db.Series.AsNoTracking()
.Where(x => x.ChangedSince != null && !schonDa.Contains(x.Id))
.OrderBy(x => x.SortTitleValue)
.Select(x => new Pruefeintrag(
"series", x.Id, x.Title, x.Year, x.PosterImage, null,
null, x.TmdbId != null,
db.Episodes.Count(f => f.SeriesId == x.Id),
new List<Guid> { x.Id },
"geaendert"))
.ToListAsync(ct);
return Results.Ok(filme.Concat(gebuendelt).Concat(geaenderteSerien).ToList());
// Bei einer Serie sagt der Ordner mehr als die erste Folgendatei.
static string? Pfad(string? datei) =>
datei is null ? null : System.IO.Path.GetDirectoryName(datei);
});
// **Zur Kenntnis genommen.**
//
// Für Titel, die längst sichtbar sind und sich nur geändert haben.
// Veröffentlichen wäre dort das falsche Wort — es gibt nichts
// freizugeben, nur etwas abzuhaken.
gruppe.MapPost("/acknowledge", async (
FreigabeRequest anfrage, FlaroContext db, CancellationToken ct) =>
{
var ids = anfrage.Ids ?? [];
var filme = ids.Count == 0
? await db.Movies.Where(m => m.ChangedSince != null)
.ExecuteUpdateAsync(s => s.SetProperty(m => m.ChangedSince, (DateTimeOffset?)null), ct)
: await db.Movies.Where(m => ids.Contains(m.Id) && m.ChangedSince != null)
.ExecuteUpdateAsync(s => s.SetProperty(m => m.ChangedSince, (DateTimeOffset?)null), ct);
var serien = ids.Count == 0
? await db.Series.Where(x => x.ChangedSince != null)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.ChangedSince, (DateTimeOffset?)null), ct)
: await db.Series.Where(x => ids.Contains(x.Id) && x.ChangedSince != null)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.ChangedSince, (DateTimeOffset?)null), ct);
return Results.Ok(new { Acknowledged = filme + serien });
});
// Veröffentlichen oder planen. Ohne Zeitpunkt heisst: jetzt.
gruppe.MapPost("/publish", async (
FreigabeRequest anfrage, FlaroContext db, TimeProvider uhr, CancellationToken ct) =>
@@ -1,3 +1,4 @@
using Flaro.Api.Auth;
using Flaro.Core.Library;
using Flaro.Core.Media;
using System.Security.Claims;
@@ -90,21 +91,70 @@ public static class LibraryContentEndpoints
s.IsDefault, s.IsForced, s.Channels, s.Width, s.Height,
Display: s.Kind switch
{
MediaStreamKind.Video => Benennung.Bildspur(s.Codec, s.Height),
MediaStreamKind.Video => Benennung.Bildspur(s.Codec, s.Width, s.Height),
MediaStreamKind.Audio => Benennung.Tonspur(s.Language, s.Codec, s.Channels, s.Title),
_ => Benennung.Untertitelspur(s.Language, s.IsForced, fuerHoergeschaedigte: false),
}));
},
LanguageName: Benennung.Sprache(s.Language)));
}
private static MediaFileResponse Datei(
Guid id, string pfad, long bytes, string? schachtel, TimeSpan? dauer,
ILookup<Guid, MediaStreamResponse> spuren, string? fassung = null) =>
ILookup<Guid, MediaStreamResponse> spuren, string? fassung = null,
string? mediathekspfad = null) =>
new(id, System.IO.Path.GetFileName(pfad), bytes, schachtel, dauer?.TotalSeconds,
spuren[id].ToList(),
// Steht sie nicht in der Datenbank, wird sie aus dem Pfad gelesen:
// ein Bestand von vor dieser Änderung soll nicht erst neu gescannt
// werden müssen, um „Mobil" zu zeigen.
fassung ?? FileNames.Fassung(pfad));
fassung ?? FileNames.Fassung(pfad),
Ordner(pfad, mediathekspfad));
/// <summary>Der Pfad der Mediathek — nur für Administratoren.</summary>
///
/// <remarks>
/// <b>Hier entscheidet sich, wer den Ordner sieht.</b> Ohne Wurzel bleibt
/// <see cref="Ordner"/> bei <c>null</c>, und das Feld kommt erst gar nicht
/// in der Antwort an — statt es im Client zu verstecken, wo es jeder
/// auslesen könnte.
/// </remarks>
private static async Task<string?> MediathekspfadFuer(
FlaroContext db, ClaimsPrincipal wer, Guid bibliothek)
{
if (!wer.IsInRole(FlaroRoles.Administrator)) return null;
return await db.Libraries.AsNoTracking()
.Where(l => l.Id == bibliothek)
.Select(l => l.Path)
.FirstOrDefaultAsync();
}
/// <summary>Der Ordner unterhalb der Mediathek — oder <c>null</c>.</summary>
///
/// <remarks>
/// <b>Nur der Teil unterhalb, nie der volle Pfad.</b> Der verriete jedem
/// Angemeldeten den Aufbau des Servers; was zum Beurteilen einer Zuordnung
/// nötig ist, ist der Ordnername — „Hanni und Nanni" neben dem Titel
/// „Hanni &amp; Nanni 3" sagt in einer Sekunde, dass etwas nicht stimmt.
///
/// <c>null</c> für alle, die nicht verwalten dürfen: dort wird der
/// Mediathekspfad gar nicht erst hereingereicht.
/// </remarks>
private static string? Ordner(string pfad, string? mediathekspfad)
{
if (string.IsNullOrEmpty(mediathekspfad)) return null;
var ordner = System.IO.Path.GetDirectoryName(pfad);
if (string.IsNullOrEmpty(ordner)) return null;
var wurzel = mediathekspfad.TrimEnd('/', '\\');
if (!ordner.StartsWith(wurzel, StringComparison.Ordinal)) return null;
var darunter = ordner[wurzel.Length..].Trim('/', '\\');
// Liegt die Datei unmittelbar in der Mediathek, gibt es keinen Ordner
// — und „" wäre eine Zeile, die nichts sagt.
return darunter.Length > 0 ? darunter : null;
}
/// <summary>Die Genres eines Titels, in der Reihenfolge der Quelle.</summary>
private static async Task<IReadOnlyList<string>> GenresZu(FlaroContext db, Guid? film, Guid? serie) =>
@@ -559,6 +609,7 @@ public static class LibraryContentEndpoints
.ToListAsync();
var spuren = await SpurenZu(db, dateien.Select(f => f.Id).ToList());
var stände = await ZuständeZu(db, wer, filme: [id]);
var wurzel = await MediathekspfadFuer(db, wer, film.LibraryId);
var sprache = await SpracheVon(wer, db);
var uebersetzt = sprache == null ? null : await db.Translations.AsNoTracking()
@@ -567,7 +618,7 @@ public static class LibraryContentEndpoints
return Results.Ok(new MovieResponse(
film.Id, uebersetzt?.Title ?? film.Title, film.Year,
uebersetzt?.Overview ?? film.Overview, film.PosterImage, film.BackdropImage,
dateien.Select(f => Datei(f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, spuren, f.VersionLabel)).ToList(),
dateien.Select(f => Datei(f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, spuren, f.VersionLabel, wurzel)).ToList(),
stände.GetValueOrDefault(id),
film.LogoImage, film.RuntimeMinutes, film.Rating,
uebersetzt?.Tagline ?? film.Tagline, film.ContentRating,
@@ -671,6 +722,10 @@ public static class LibraryContentEndpoints
.Select(f => new { f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview, f.StillImage })
.ToListAsync();
var wurzel = await MediathekspfadFuer(db, wer,
await db.Series.AsNoTracking().Where(x => x.Id == id)
.Select(x => x.LibraryId).FirstAsync());
var ids = folgen.Select(f => f.Id).ToList();
var dateien = await db.MediaFiles.AsNoTracking()
.Where(d => d.EpisodeId != null && ids.Contains(d.EpisodeId!.Value))
@@ -687,7 +742,7 @@ public static class LibraryContentEndpoints
f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview,
f.StillImage ?? serienBild,
dateien.Where(d => d.EpisodeId == f.Id)
.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren, d.VersionLabel)).ToList(),
.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren, d.VersionLabel, wurzel)).ToList(),
stände.GetValueOrDefault(f.Id))).ToList();
return Results.Ok(new Page<EpisodeResponse>(items, items.Count));
@@ -32,10 +32,18 @@ public static class StreamEndpoints
// was der Benutzer gewählt hat.
app.MapPost("/api/v1/files/{id:guid}/stream", async (
Guid id, StreamRequest anfrage, ClaimsPrincipal wer,
Streamkoordinator koordinator, FlaroContext db) =>
Streamkoordinator koordinator, FlaroContext db, TimeProvider uhr) =>
{
if (PlaybackEndpoints.Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
// **Geplant heisst sichtbar, nicht abspielbar.** Ohne diese
// Prüfung liesse sich ein Titel, der mit „Verfügbar ab …" dasteht,
// trotzdem abspielen — und die Angabe wäre eine Zierde.
var gewuenschte = await db.MediaFiles.AsNoTracking().FirstOrDefaultAsync(f => f.Id == id);
if (gewuenschte is null) return Results.NotFound();
if (await DeliveryEndpoints.FreiAb(db, gewuenschte, uhr.GetUtcNow()) is { } nochNicht)
return DeliveryEndpoints.NochNicht(nochNicht);
var profil = Profil(anfrage);
var hoehe = anfrage.Height is { } gewuenscht
&& Stromentscheid.Stufen.Contains(gewuenscht) ? gewuenscht : (int?)null;