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:
co-authored by
Claude Opus 5
parent
f44779c899
commit
255eb7c2f0
@@ -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 & 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 & 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;
|
||||
|
||||
Reference in New Issue
Block a user