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> /// <summary>Wie viele Titel dahinterstecken — bei einer Serie die Zahl der Folgen.</summary>
int Count, int Count,
/// <summary>Worauf eine Freigabe wirkt.</summary> /// <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( public sealed record FreigabeRequest(
IReadOnlyList<Guid>? Ids, IReadOnlyList<Guid>? Ids,
@@ -256,7 +268,17 @@ public sealed record MediaStreamResponse(
/// dieselbe Tonspur am Fernseher anders als im Browser. Die Rohwerte /// dieselbe Tonspur am Fernseher anders als im Browser. Die Rohwerte
/// stehen weiterhin daneben; wer sie braucht, hat sie. /// stehen weiterhin daneben; wer sie braucht, hat sie.
/// </remarks> /// </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> /// <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 /// derselben Folge als zwei gleich aussehende Knopfreihen untereinander, und
/// die Folge wirkte doppelt. /// die Folge wirkte doppelt.
/// </param> /// </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( public sealed record MediaFileResponse(
Guid Id, string FileName, long SizeBytes, string? Container, double? DurationSeconds, 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( public sealed record MovieResponse(
Guid Id, string Title, int? Year, string? Overview, 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. // durch bis zum SPA-Rückfall und bekäme die Webseite mit Status 200.
// Genau das ist passiert: der Spieler bekam HTML statt Video. // Genau das ist passiert: der Spieler bekam HTML statt Video.
app.MapMethods("/api/v1/files/{id:guid}/download", ["GET", "HEAD"], async ( 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); var gefunden = await Laden(db, id);
if (gefunden is not var (datei, plan)) return Results.NotFound(); 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 // Passt schon alles, geht die Datei unverändert hinaus — mit
// Längenangabe und Bereichsanfragen, also mit Fortschritt und // Längenangabe und Bereichsanfragen, also mit Fortschritt und
// Fortsetzen. // Fortsetzen.
@@ -70,6 +72,41 @@ public static class DeliveryEndpoints
}).RequireAuthorization(); }).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) private static async Task<(MediaFile Datei, DeliveryPlan Plan)?> Laden(FlaroContext db, Guid id)
{ {
var datei = await db.MediaFiles.AsNoTracking().FirstOrDefaultAsync(f => f.Id == 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 jetzt = uhr.GetUtcNow();
var filme = await db.Movies.AsNoTracking() 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) .OrderBy(m => m.SortTitleValue)
.Select(m => new Pruefeintrag( .Select(m => new Pruefeintrag(
"movie", m.Id, m.Title, m.Year, m.PosterImage, m.PublishAt, "movie", m.Id, m.Title, m.Year, m.PosterImage, m.PublishAt,
db.MediaFiles.Where(f => f.MovieId == m.Id).Select(f => f.Path).FirstOrDefault(), 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); .ToListAsync(ct);
// **Folgen werden nach Serie gebündelt.** Zwanzig neue Folgen // **Folgen werden nach Serie gebündelt.** Zwanzig neue Folgen
@@ -71,13 +75,54 @@ public static class FreigabeEndpoints
.OrderBy(e => e.Title) .OrderBy(e => e.Title)
.ToList(); .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. // Bei einer Serie sagt der Ordner mehr als die erste Folgendatei.
static string? Pfad(string? datei) => static string? Pfad(string? datei) =>
datei is null ? null : System.IO.Path.GetDirectoryName(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. // Veröffentlichen oder planen. Ohne Zeitpunkt heisst: jetzt.
gruppe.MapPost("/publish", async ( gruppe.MapPost("/publish", async (
FreigabeRequest anfrage, FlaroContext db, TimeProvider uhr, CancellationToken ct) => FreigabeRequest anfrage, FlaroContext db, TimeProvider uhr, CancellationToken ct) =>
@@ -1,3 +1,4 @@
using Flaro.Api.Auth;
using Flaro.Core.Library; using Flaro.Core.Library;
using Flaro.Core.Media; using Flaro.Core.Media;
using System.Security.Claims; using System.Security.Claims;
@@ -90,21 +91,70 @@ public static class LibraryContentEndpoints
s.IsDefault, s.IsForced, s.Channels, s.Width, s.Height, s.IsDefault, s.IsForced, s.Channels, s.Width, s.Height,
Display: s.Kind switch 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), MediaStreamKind.Audio => Benennung.Tonspur(s.Language, s.Codec, s.Channels, s.Title),
_ => Benennung.Untertitelspur(s.Language, s.IsForced, fuerHoergeschaedigte: false), _ => Benennung.Untertitelspur(s.Language, s.IsForced, fuerHoergeschaedigte: false),
})); },
LanguageName: Benennung.Sprache(s.Language)));
} }
private static MediaFileResponse Datei( private static MediaFileResponse Datei(
Guid id, string pfad, long bytes, string? schachtel, TimeSpan? dauer, 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, new(id, System.IO.Path.GetFileName(pfad), bytes, schachtel, dauer?.TotalSeconds,
spuren[id].ToList(), spuren[id].ToList(),
// Steht sie nicht in der Datenbank, wird sie aus dem Pfad gelesen: // Steht sie nicht in der Datenbank, wird sie aus dem Pfad gelesen:
// ein Bestand von vor dieser Änderung soll nicht erst neu gescannt // ein Bestand von vor dieser Änderung soll nicht erst neu gescannt
// werden müssen, um „Mobil" zu zeigen. // 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> /// <summary>Die Genres eines Titels, in der Reihenfolge der Quelle.</summary>
private static async Task<IReadOnlyList<string>> GenresZu(FlaroContext db, Guid? film, Guid? serie) => private static async Task<IReadOnlyList<string>> GenresZu(FlaroContext db, Guid? film, Guid? serie) =>
@@ -559,6 +609,7 @@ public static class LibraryContentEndpoints
.ToListAsync(); .ToListAsync();
var spuren = await SpurenZu(db, dateien.Select(f => f.Id).ToList()); var spuren = await SpurenZu(db, dateien.Select(f => f.Id).ToList());
var stände = await ZuständeZu(db, wer, filme: [id]); 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 sprache = await SpracheVon(wer, db);
var uebersetzt = sprache == null ? null : await db.Translations.AsNoTracking() var uebersetzt = sprache == null ? null : await db.Translations.AsNoTracking()
@@ -567,7 +618,7 @@ public static class LibraryContentEndpoints
return Results.Ok(new MovieResponse( return Results.Ok(new MovieResponse(
film.Id, uebersetzt?.Title ?? film.Title, film.Year, film.Id, uebersetzt?.Title ?? film.Title, film.Year,
uebersetzt?.Overview ?? film.Overview, film.PosterImage, film.BackdropImage, 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), stände.GetValueOrDefault(id),
film.LogoImage, film.RuntimeMinutes, film.Rating, film.LogoImage, film.RuntimeMinutes, film.Rating,
uebersetzt?.Tagline ?? film.Tagline, film.ContentRating, 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 }) .Select(f => new { f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview, f.StillImage })
.ToListAsync(); .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 ids = folgen.Select(f => f.Id).ToList();
var dateien = await db.MediaFiles.AsNoTracking() var dateien = await db.MediaFiles.AsNoTracking()
.Where(d => d.EpisodeId != null && ids.Contains(d.EpisodeId!.Value)) .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.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview,
f.StillImage ?? serienBild, f.StillImage ?? serienBild,
dateien.Where(d => d.EpisodeId == f.Id) 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(); stände.GetValueOrDefault(f.Id))).ToList();
return Results.Ok(new Page<EpisodeResponse>(items, items.Count)); return Results.Ok(new Page<EpisodeResponse>(items, items.Count));
@@ -32,10 +32,18 @@ public static class StreamEndpoints
// was der Benutzer gewählt hat. // was der Benutzer gewählt hat.
app.MapPost("/api/v1/files/{id:guid}/stream", async ( app.MapPost("/api/v1/files/{id:guid}/stream", async (
Guid id, StreamRequest anfrage, ClaimsPrincipal wer, 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(); 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 profil = Profil(anfrage);
var hoehe = anfrage.Height is { } gewuenscht var hoehe = anfrage.Height is { } gewuenscht
&& Stromentscheid.Stufen.Contains(gewuenscht) ? gewuenscht : (int?)null; && Stromentscheid.Stufen.Contains(gewuenscht) ? gewuenscht : (int?)null;
+41
View File
@@ -1,3 +1,4 @@
using System.Linq;
using System.Text; using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Flaro.Data; using Flaro.Data;
@@ -76,16 +77,56 @@ public static partial class Fehlertext
{ {
var text = new StringBuilder(); var text = new StringBuilder();
var tiefe = 0; var tiefe = 0;
Exception innerste = fehler;
for (Exception? e = fehler; e is not null; e = e.InnerException, tiefe++) for (Exception? e = fehler; e is not null; e = e.InnerException, tiefe++)
{ {
if (tiefe > 0) text.Append(" ← "); if (tiefe > 0) text.Append(" ← ");
text.Append(e.GetType().Name).Append(": ").Append(e.Message); text.Append(e.GetType().Name).Append(": ").Append(e.Message);
innerste = e;
} }
// **Und wo es passiert ist.**
//
// Ohne diese Zeilen stand in der Tagesdatei „InvalidOperationException:
// Nullable object must have a value." und sonst nichts — eine Meldung,
// mit der weder der Betreiber noch der Entwickler etwas anfangen kann.
// Gerade die Fehler, die man nicht erwartet hat, sind die, bei denen
// die Stelle alles ist.
//
// Nur die innerste Ausnahme und nur die eigenen Bilder: der Rahmenwerk-
// Anteil einer Spur ist dreimal so lang und sagt nichts über Flaro.
var spur = Eigene(innerste.StackTrace);
if (spur.Length > 0) text.Append('\n').Append(spur);
return text.ToString(); return text.ToString();
} }
/// <summary>Die Zeilen der Spur, die zu Flaro gehören.</summary>
///
/// <remarks>
/// <b>Höchstens zwölf.</b> Was darunter liegt, ist ASP.NET und EF Core —
/// bei einem Fehler im eigenen Code steht die Antwort immer oben.
/// </remarks>
private static string Eigene(string? spur)
{
if (string.IsNullOrEmpty(spur)) return "";
var zeilen = spur.Split('\n')
.Select(z => z.TrimEnd('\r'))
.Where(z => z.Contains(" Flaro.", StringComparison.Ordinal))
.Take(12)
.ToList();
// Steckt gar nichts Eigenes darin, ist die ganze Spur besser als keine.
if (zeilen.Count == 0)
{
zeilen = spur.Split('\n').Select(z => z.TrimEnd('\r')).Take(6).ToList();
}
return string.Join('\n', zeilen);
}
/// <summary>Welche Eindeutigkeitsregel gebrochen wurde — in Worten.</summary> /// <summary>Welche Eindeutigkeitsregel gebrochen wurde — in Worten.</summary>
/// ///
/// <remarks> /// <remarks>
@@ -169,6 +169,24 @@ public sealed class Movie : Flaro.Core.Metadata.IBebildert
/// </remarks> /// </remarks>
public bool SortTitlePinned { get; set; } public bool SortTitlePinned { get; set; }
/// <summary>Seit wann eine Änderung auf ihre Kenntnisnahme wartet.</summary>
///
/// <remarks>
/// <para>
/// <b>Nicht dasselbe wie <see cref="PublishAt"/>.</b> Der Titel bleibt
/// sichtbar — es geht nicht darum, ihn zurückzuhalten, sondern darum, dass
/// jemand hinsieht. Ein Auffrischen kann einen Titel umbenennen, ein
/// Plakat tauschen oder die Zuordnung wechseln; wer eine Mediathek
/// betreut, will das mitbekommen und nicht zufällig entdecken.
/// </para>
/// <para>
/// <c>null</c> heisst: nichts offen. Gesetzt wird es nur, wenn die
/// Mediathek auf Prüfung steht — wer keine Prüfung will, soll auch keine
/// Liste bekommen.
/// </para>
/// </remarks>
public DateTimeOffset? ChangedSince { get; set; }
/// <summary>In welcher Sprache die Angaben geholt wurden.</summary> /// <summary>In welcher Sprache die Angaben geholt wurden.</summary>
/// ///
/// <remarks> /// <remarks>
@@ -350,6 +368,24 @@ public sealed class Series : Flaro.Core.Metadata.IBebildert
/// </remarks> /// </remarks>
public bool SortTitlePinned { get; set; } public bool SortTitlePinned { get; set; }
/// <summary>Seit wann eine Änderung auf ihre Kenntnisnahme wartet.</summary>
///
/// <remarks>
/// <para>
/// <b>Nicht dasselbe wie <see cref="PublishAt"/>.</b> Der Titel bleibt
/// sichtbar — es geht nicht darum, ihn zurückzuhalten, sondern darum, dass
/// jemand hinsieht. Ein Auffrischen kann einen Titel umbenennen, ein
/// Plakat tauschen oder die Zuordnung wechseln; wer eine Mediathek
/// betreut, will das mitbekommen und nicht zufällig entdecken.
/// </para>
/// <para>
/// <c>null</c> heisst: nichts offen. Gesetzt wird es nur, wenn die
/// Mediathek auf Prüfung steht — wer keine Prüfung will, soll auch keine
/// Liste bekommen.
/// </para>
/// </remarks>
public DateTimeOffset? ChangedSince { get; set; }
/// <summary>In welcher Sprache die Angaben geholt wurden.</summary> /// <summary>In welcher Sprache die Angaben geholt wurden.</summary>
/// ///
/// <remarks> /// <remarks>
+44 -3
View File
@@ -88,7 +88,45 @@ public static class Benennung
/// Zeilen statt 1080; genau das stand in der Oberfläche und sah aus wie /// Zeilen statt 1080; genau das stand in der Oberfläche und sah aus wie
/// ein Fehler. Der genaue Wert gehört in den Tooltip, nicht in die Zeile. /// ein Fehler. Der genaue Wert gehört in den Tooltip, nicht in die Zeile.
/// </remarks> /// </remarks>
public static string? Aufloesung(int? hoehe) => hoehe switch public static string? Aufloesung(int? hoehe) => AusHoehe(hoehe);
/// <summary>Die Stufe aus Breite <i>und</i> Höhe — die grössere gewinnt.</summary>
///
/// <remarks>
/// <para>
/// <b>Die Höhe allein lügt bei Breitbild.</b> „Spider-Man" liegt als
/// 1920×800 vor — ein Kinoformat von 2.39:1, also Full HD. Nach der Höhe
/// gerechnet kam „720p" heraus, und das stand so in der Oberfläche: eine
/// Angabe, die jeder sofort als falsch erkennt, der die Datei kennt.
/// </para>
/// <para>
/// <b>Die grössere der beiden Stufen, nicht die Breite allein.</b> Sonst
/// kippt es beim Hochformat: ein 1080×1920 gedrehtes Bild wäre nach der
/// Breite „720p". So stimmt beides, und keine Richtung ist bevorzugt.
/// </para>
/// </remarks>
public static string? Aufloesung(int? breite, int? hoehe)
{
var a = Stufe(hoehe);
var b = Stufe(breite * 9 / 16); // Breite auf eine 16:9-Höhe umgerechnet
var groessere = Math.Max(a ?? 0, b ?? 0);
return groessere > 0 ? AusHoehe(groessere) : null;
}
/// <summary>Die Zeilenzahl, auf die eine Stufe lautet — zum Vergleichen.</summary>
private static int? Stufe(int? wert) => wert switch
{
null or <= 0 => null,
>= 2000 => 2160,
>= 1400 => 1440,
>= 1000 => 1080,
>= 700 => 720,
>= 500 => 576,
>= 300 => 480,
_ => wert,
};
private static string? AusHoehe(int? hoehe) => hoehe switch
{ {
null or <= 0 => null, null or <= 0 => null,
>= 2000 => "2160p", >= 2000 => "2160p",
@@ -133,10 +171,13 @@ public static class Benennung
} }
/// <summary>Die ganze Zeile zu einer Bildspur.</summary> /// <summary>Die ganze Zeile zu einer Bildspur.</summary>
public static string Bildspur(string? codec, int? hoehe) public static string Bildspur(string? codec, int? hoehe) => Bildspur(codec, null, hoehe);
/// <inheritdoc cref="Bildspur(string?, int?)"/>
public static string Bildspur(string? codec, int? breite, int? hoehe)
{ {
var teile = new List<string>(); var teile = new List<string>();
if (Aufloesung(hoehe) is { } a) teile.Add(a); if (Aufloesung(breite, hoehe) is { } a) teile.Add(a);
if (Codec(codec) is { } c) teile.Add(c); if (Codec(codec) is { } c) teile.Add(c);
return teile.Count > 0 ? string.Join(" · ", teile) : "Bild"; return teile.Count > 0 ? string.Join(" · ", teile) : "Bild";
} }
+19 -7
View File
@@ -278,22 +278,34 @@ public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbCon
// **Die Freigaberegel, einmal und für jede Abfrage.** // **Die Freigaberegel, einmal und für jede Abfrage.**
// //
// Ein Titel ist sichtbar, wenn ein Zeitpunkt gesetzt ist und er // <b>Zwei Zustände, nicht einer.</b> Ohne Zeitpunkt wartet ein Titel
// erreicht wurde. Gerechnet statt gespeichert: damit stimmt es nach // auf eine Entscheidung — den soll niemand sehen, er ist noch nicht
// einem Neustart, nach einer Zeitumstellung und auch dann, wenn der // einmal geprüft. Mit einem Zeitpunkt in der Zukunft ist die
// Server zum geplanten Zeitpunkt gar nicht lief. // Entscheidung gefallen: er kommt, nur noch nicht. Den zu verstecken
// hiesse, eine Vorfreude zu unterschlagen, die der Sinn des Planens
// ist — bei Netflix steht „Ab 14. März" auf der Kachel, und genau das
// soll hier auch stehen.
//
// <b>Sichtbar heisst nicht abspielbar.</b> Dass die Datei vor dem
// Zeitpunkt nicht herausgeht, steht nicht hier, sondern bei der
// Auslieferung — ein Filter, der etwas zeigt, darf nicht zugleich der
// sein, der es herausgibt.
//
// Gerechnet statt gespeichert: damit stimmt es nach einem Neustart,
// nach einer Zeitumstellung und auch dann, wenn der Server zum
// geplanten Zeitpunkt gar nicht lief.
b.Entity<Movie>().HasQueryFilter(m => b.Entity<Movie>().HasQueryFilter(m =>
AuchUnveroeffentlichtes || (m.PublishAt != null && m.PublishAt <= Jetzt)); AuchUnveroeffentlichtes || m.PublishAt != null);
b.Entity<Episode>().HasQueryFilter(f => b.Entity<Episode>().HasQueryFilter(f =>
AuchUnveroeffentlichtes || (f.PublishAt != null && f.PublishAt <= Jetzt)); AuchUnveroeffentlichtes || f.PublishAt != null);
// **Die Serie hängt an ihren Folgen.** Ein eigenes Feld gäbe zwei // **Die Serie hängt an ihren Folgen.** Ein eigenes Feld gäbe zwei
// Wahrheiten — eine veröffentlichte Serie, deren Folgen alle noch // Wahrheiten — eine veröffentlichte Serie, deren Folgen alle noch
// warten, wäre ein leerer Eintrag in der Mediathek. // warten, wäre ein leerer Eintrag in der Mediathek.
b.Entity<Series>().HasQueryFilter(s => b.Entity<Series>().HasQueryFilter(s =>
AuchUnveroeffentlichtes AuchUnveroeffentlichtes
|| Episodes.Any(f => f.SeriesId == s.Id && f.PublishAt != null && f.PublishAt <= Jetzt)); || Episodes.Any(f => f.SeriesId == s.Id && f.PublishAt != null));
b.Entity<ItemTranslation>(e => b.Entity<ItemTranslation>(e =>
{ {
+34 -4
View File
@@ -31,6 +31,7 @@ public sealed class MetadataEnricher(
IImageReader bildLeser, IImageReader bildLeser,
ISidecarStore beiwerk, ISidecarStore beiwerk,
Zusammenlegung zusammenlegung, Zusammenlegung zusammenlegung,
TimeProvider uhr,
ILogger<MetadataEnricher> log) ILogger<MetadataEnricher> log)
{ {
/// <param name="erneut"> /// <param name="erneut">
@@ -66,11 +67,18 @@ public sealed class MetadataEnricher(
return new EnrichReport(0, 0, 0, Skipped: true, null); return new EnrichReport(0, 0, 0, Skipped: true, null);
} }
// **Ob die Mediathek auf Prüfung steht.** Nur dann wird eine Änderung
// vorgemerkt — wer keine Prüfung will, soll auch keine Liste bekommen.
var pruefungNoetig = await db.Libraries.AsNoTracking()
.Where(l => l.Id == libraryId)
.Select(l => l.ReviewNewItems)
.FirstOrDefaultAsync(ct);
int filme = 0, serien = 0, folgen = 0; int filme = 0, serien = 0, folgen = 0;
try try
{ {
filme = await FilmeZuordnen(libraryId, erneut, fortschritt, ct, nurSprache, nurDiese); filme = await FilmeZuordnen(libraryId, erneut, fortschritt, ct, nurSprache, nurDiese, pruefungNoetig);
serien = await SerienZuordnen(libraryId, erneut, fortschritt, ct, nurSprache, nurDiese); serien = await SerienZuordnen(libraryId, erneut, fortschritt, ct, nurSprache, nurDiese, pruefungNoetig);
folgen = await FolgenZuordnen(libraryId, erneut, fortschritt, ct, nurSprache, nurDiese); folgen = await FolgenZuordnen(libraryId, erneut, fortschritt, ct, nurSprache, nurDiese);
} }
catch (MetadataCredentialsException fehler) catch (MetadataCredentialsException fehler)
@@ -88,7 +96,7 @@ public sealed class MetadataEnricher(
private async Task<int> FilmeZuordnen( private async Task<int> FilmeZuordnen(
Guid libraryId, bool erneut, IProgress<Scanschritt>? fortschritt, CancellationToken ct, Guid libraryId, bool erneut, IProgress<Scanschritt>? fortschritt, CancellationToken ct,
string? nurSprache = null, IReadOnlySet<Guid>? nurDiese = null) string? nurSprache = null, IReadOnlySet<Guid>? nurDiese = null, bool pruefungNoetig = false)
{ {
var offen = await db.Movies var offen = await db.Movies
.Where(m => m.LibraryId == libraryId && (erneut || m.TmdbId == null) .Where(m => m.LibraryId == libraryId && (erneut || m.TmdbId == null)
@@ -190,6 +198,11 @@ public sealed class MetadataEnricher(
continue; continue;
} }
// **Was vorher dastand** — nur daran lässt sich sagen, ob ein
// Auffrischen wirklich etwas geändert hat. Ein Lauf, der denselben
// Titel noch einmal holt, ist nichts zum Abnicken.
var vorher = (film.TmdbId, film.Title, film.PosterImage, film.Year);
vergeben[gefunden.ProviderId] = film.Id; vergeben[gefunden.ProviderId] = film.Id;
// **Auch das Jahr, und zwar sofort.** Die beiden Tabellen müssen // **Auch das Jahr, und zwar sofort.** Die beiden Tabellen müssen
// im Gleichschritt wachsen: wird nur `vergeben` fortgeschrieben, // im Gleichschritt wachsen: wird nur `vergeben` fortgeschrieben,
@@ -229,6 +242,15 @@ public sealed class MetadataEnricher(
await MitwirkendeSetzen(gefunden.Credits, film: film.Id, serie: null, ct); await MitwirkendeSetzen(gefunden.Credits, film: film.Id, serie: null, ct);
zugeordnet++; zugeordnet++;
// **Eine Änderung an einem Titel, den es schon gab, wird vorgemerkt.**
// Bei einem neuen Fund nicht: der steht ohnehin schon in der
// Prüfliste, weil er auf Freigabe wartet.
if (pruefungNoetig && vorher.TmdbId is not null
&& vorher != (film.TmdbId, film.Title, film.PosterImage, film.Year))
{
film.ChangedSince ??= uhr.GetUtcNow();
}
if (datei is not null) await FilmBeiwerk(film, datei, ct); if (datei is not null) await FilmBeiwerk(film, datei, ct);
} }
@@ -299,7 +321,7 @@ public sealed class MetadataEnricher(
private async Task<int> SerienZuordnen( private async Task<int> SerienZuordnen(
Guid libraryId, bool erneut, IProgress<Scanschritt>? fortschritt, CancellationToken ct, Guid libraryId, bool erneut, IProgress<Scanschritt>? fortschritt, CancellationToken ct,
string? nurSprache = null, IReadOnlySet<Guid>? nurDiese = null) string? nurSprache = null, IReadOnlySet<Guid>? nurDiese = null, bool pruefungNoetig = false)
{ {
var offen = await db.Series var offen = await db.Series
.Where(s => s.LibraryId == libraryId && (erneut || s.TmdbId == null) .Where(s => s.LibraryId == libraryId && (erneut || s.TmdbId == null)
@@ -349,6 +371,8 @@ public sealed class MetadataEnricher(
} }
vergeben[gefunden.ProviderId] = serie.Id; vergeben[gefunden.ProviderId] = serie.Id;
var vorher = (serie.TmdbId, serie.Title, serie.PosterImage, serie.Year);
serie.TmdbId = gefunden.ProviderId; serie.TmdbId = gefunden.ProviderId;
serie.ImdbId = gefunden.ImdbId; serie.ImdbId = gefunden.ImdbId;
if (!serie.TitlePinned) if (!serie.TitlePinned)
@@ -373,6 +397,12 @@ public sealed class MetadataEnricher(
await GenresSetzen(gefunden.Genres, film: null, serie: serie.Id, ct); await GenresSetzen(gefunden.Genres, film: null, serie: serie.Id, ct);
await MitwirkendeSetzen(gefunden.Credits, film: null, serie: serie.Id, ct); await MitwirkendeSetzen(gefunden.Credits, film: null, serie: serie.Id, ct);
if (pruefungNoetig && vorher.TmdbId is not null
&& vorher != (serie.TmdbId, serie.Title, serie.PosterImage, serie.Year))
{
serie.ChangedSince ??= uhr.GetUtcNow();
}
serie.MatchLocked = festgelegt; serie.MatchLocked = festgelegt;
zugeordnet++; zugeordnet++;
@@ -0,0 +1,933 @@
// <auto-generated />
using System;
using Flaro.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Flaro.Data.Migrations
{
[DbContext(typeof(FlaroContext))]
[Migration("20261006123929_Kenntnisnahme")]
partial class Kenntnisnahme
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.12");
modelBuilder.Entity("Flaro.Core.Auth.AuthCode", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid?>("ApprovedByUserId")
.HasColumnType("TEXT");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<long?>("ConsumedAt")
.HasColumnType("INTEGER");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("DeviceName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("ExpiresAt")
.HasColumnType("INTEGER");
b.Property<string>("HandleFingerprint")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Code");
b.HasIndex("HandleFingerprint")
.IsUnique();
b.ToTable("AuthCodes");
});
modelBuilder.Entity("Flaro.Core.Auth.DeviceToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("DeviceName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("LastSeenAt")
.HasColumnType("INTEGER");
b.Property<long?>("RevokedAt")
.HasColumnType("INTEGER");
b.Property<string>("TokenFingerprint")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TokenFingerprint")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("DeviceTokens");
});
modelBuilder.Entity("Flaro.Core.Libraries.Library", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<bool>("ReviewNewItems")
.HasColumnType("INTEGER");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("Libraries");
});
modelBuilder.Entity("Flaro.Core.Library.Credit", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Character")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<int>("Order")
.HasColumnType("INTEGER");
b.Property<Guid>("PersonId")
.HasColumnType("TEXT");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<Guid?>("SeriesId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("MovieId");
b.HasIndex("PersonId");
b.HasIndex("SeriesId");
b.ToTable("Credits");
});
modelBuilder.Entity("Flaro.Core.Library.Episode", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<int>("EpisodeNumber")
.HasColumnType("INTEGER");
b.Property<string>("MetadataLanguage")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<long?>("PublishAt")
.HasColumnType("INTEGER");
b.Property<int>("SeasonNumber")
.HasColumnType("INTEGER");
b.Property<Guid>("SeriesId")
.HasColumnType("TEXT");
b.Property<string>("StillImage")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("SeriesId", "SeasonNumber", "EpisodeNumber")
.IsUnique();
b.ToTable("Episodes");
});
modelBuilder.Entity("Flaro.Core.Library.Genre", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Genres");
});
modelBuilder.Entity("Flaro.Core.Library.GenreLink", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("GenreId")
.HasColumnType("TEXT");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<Guid?>("SeriesId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("MovieId");
b.HasIndex("SeriesId");
b.HasIndex("GenreId", "MovieId")
.IsUnique()
.HasFilter("\"MovieId\" IS NOT NULL");
b.HasIndex("GenreId", "SeriesId")
.IsUnique()
.HasFilter("\"SeriesId\" IS NOT NULL");
b.ToTable("GenreLinks");
});
modelBuilder.Entity("Flaro.Core.Library.ItemTranslation", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid?>("EpisodeId")
.HasColumnType("TEXT");
b.Property<long>("FetchedAt")
.HasColumnType("INTEGER");
b.Property<string>("Language")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<Guid?>("SeriesId")
.HasColumnType("TEXT");
b.Property<string>("Tagline")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("EpisodeId", "Language")
.IsUnique()
.HasFilter("\"EpisodeId\" IS NOT NULL");
b.HasIndex("MovieId", "Language")
.IsUnique()
.HasFilter("\"MovieId\" IS NOT NULL");
b.HasIndex("SeriesId", "Language")
.IsUnique()
.HasFilter("\"SeriesId\" IS NOT NULL");
b.ToTable("Translations");
});
modelBuilder.Entity("Flaro.Core.Library.MediaFile", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long?>("BitRate")
.HasColumnType("INTEGER");
b.Property<string>("Container")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<long?>("Duration")
.HasColumnType("INTEGER");
b.Property<Guid?>("EpisodeId")
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<long?>("MissingSince")
.HasColumnType("INTEGER");
b.Property<long>("ModifiedAt")
.HasColumnType("INTEGER");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<long?>("ProbedAt")
.HasColumnType("INTEGER");
b.Property<long>("SeenAt")
.HasColumnType("INTEGER");
b.Property<long>("SizeBytes")
.HasColumnType("INTEGER");
b.Property<string>("VersionLabel")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("EpisodeId");
b.HasIndex("MovieId");
b.HasIndex("LibraryId", "Path")
.IsUnique();
b.ToTable("MediaFiles");
});
modelBuilder.Entity("Flaro.Core.Library.MediaStream", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<int?>("Channels")
.HasColumnType("INTEGER");
b.Property<string>("Codec")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int?>("Height")
.HasColumnType("INTEGER");
b.Property<int>("Index")
.HasColumnType("INTEGER");
b.Property<bool>("IsDefault")
.HasColumnType("INTEGER");
b.Property<bool>("IsForced")
.HasColumnType("INTEGER");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("Language")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<Guid>("MediaFileId")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int?>("Width")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("MediaFileId", "Index")
.IsUnique();
b.ToTable("MediaStreams");
});
modelBuilder.Entity("Flaro.Core.Library.Movie", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<string>("BackdropImage")
.HasColumnType("TEXT");
b.Property<bool>("BackdropPinned")
.HasColumnType("INTEGER");
b.Property<long?>("ChangedSince")
.HasColumnType("INTEGER");
b.Property<string>("ContentRating")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("ImdbId")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<string>("LogoImage")
.HasColumnType("TEXT");
b.Property<bool>("LogoPinned")
.HasColumnType("INTEGER");
b.Property<bool>("MatchLocked")
.HasColumnType("INTEGER");
b.Property<string>("MetadataLanguage")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("OriginalTitle")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<string>("ParsedTitle")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("ParsedYear")
.HasColumnType("INTEGER");
b.Property<string>("PosterImage")
.HasColumnType("TEXT");
b.Property<bool>("PosterPinned")
.HasColumnType("INTEGER");
b.Property<long?>("PublishAt")
.HasColumnType("INTEGER");
b.Property<double?>("Rating")
.HasColumnType("REAL");
b.Property<int?>("RuntimeMinutes")
.HasColumnType("INTEGER");
b.Property<bool>("SortTitlePinned")
.HasColumnType("INTEGER");
b.Property<string>("SortTitleValue")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Tagline")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<bool>("TitlePinned")
.HasColumnType("INTEGER");
b.Property<int?>("TmdbId")
.HasColumnType("INTEGER");
b.Property<int?>("Year")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("LibraryId", "MetadataLanguage");
b.HasIndex("LibraryId", "SortTitleValue");
b.HasIndex("LibraryId", "TmdbId")
.IsUnique()
.HasFilter("\"TmdbId\" IS NOT NULL");
b.ToTable("Movies");
});
modelBuilder.Entity("Flaro.Core.Library.Person", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Image")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int>("TmdbId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("TmdbId")
.IsUnique();
b.ToTable("People");
});
modelBuilder.Entity("Flaro.Core.Library.Series", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<string>("BackdropImage")
.HasColumnType("TEXT");
b.Property<bool>("BackdropPinned")
.HasColumnType("INTEGER");
b.Property<long?>("ChangedSince")
.HasColumnType("INTEGER");
b.Property<string>("ContentRating")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("ImdbId")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<string>("LogoImage")
.HasColumnType("TEXT");
b.Property<bool>("LogoPinned")
.HasColumnType("INTEGER");
b.Property<bool>("MatchLocked")
.HasColumnType("INTEGER");
b.Property<string>("MetadataLanguage")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("OriginalTitle")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<string>("ParsedTitle")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("ParsedYear")
.HasColumnType("INTEGER");
b.Property<string>("PosterImage")
.HasColumnType("TEXT");
b.Property<bool>("PosterPinned")
.HasColumnType("INTEGER");
b.Property<long?>("PublishAt")
.HasColumnType("INTEGER");
b.Property<double?>("Rating")
.HasColumnType("REAL");
b.Property<int?>("RuntimeMinutes")
.HasColumnType("INTEGER");
b.Property<bool>("SortTitlePinned")
.HasColumnType("INTEGER");
b.Property<string>("SortTitleValue")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Tagline")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<bool>("TitlePinned")
.HasColumnType("INTEGER");
b.Property<int?>("TmdbId")
.HasColumnType("INTEGER");
b.Property<int?>("Year")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("LibraryId", "MetadataLanguage");
b.HasIndex("LibraryId", "SortTitleValue");
b.HasIndex("LibraryId", "TmdbId")
.IsUnique()
.HasFilter("\"TmdbId\" IS NOT NULL");
b.ToTable("Series");
});
modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("Dismissed")
.HasColumnType("INTEGER");
b.Property<Guid?>("EpisodeId")
.HasColumnType("TEXT");
b.Property<bool>("Favourite")
.HasColumnType("INTEGER");
b.Property<bool>("InProgress")
.HasColumnType("INTEGER");
b.Property<long?>("LastPlayedAt")
.HasColumnType("INTEGER");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<int>("PlayCount")
.HasColumnType("INTEGER");
b.Property<long>("Position")
.HasColumnType("INTEGER");
b.Property<int?>("Rating")
.HasColumnType("INTEGER");
b.Property<long?>("Runtime")
.HasColumnType("INTEGER");
b.Property<Guid?>("SeriesId")
.HasColumnType("TEXT");
b.Property<long>("UpdatedAt")
.HasColumnType("INTEGER");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.Property<bool>("Watched")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("EpisodeId");
b.HasIndex("MovieId");
b.HasIndex("SeriesId");
b.HasIndex("UserId", "EpisodeId")
.IsUnique()
.HasFilter("\"EpisodeId\" IS NOT NULL");
b.HasIndex("UserId", "MovieId")
.IsUnique()
.HasFilter("\"MovieId\" IS NOT NULL");
b.HasIndex("UserId", "SeriesId")
.IsUnique()
.HasFilter("\"SeriesId\" IS NOT NULL");
b.HasIndex("UserId", "InProgress", "UpdatedAt");
b.ToTable("UserItemStates");
});
modelBuilder.Entity("Flaro.Core.ServerSetting", b =>
{
b.Property<string>("Key")
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("TEXT");
b.HasKey("Key");
b.ToTable("Settings");
});
modelBuilder.Entity("Flaro.Core.Users.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<bool>("IsAdministrator")
.HasColumnType("INTEGER");
b.Property<string>("Language")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("UserName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.HasKey("Id");
b.HasIndex("UserName")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("Flaro.Core.Auth.DeviceToken", b =>
{
b.HasOne("Flaro.Core.Users.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.Credit", b =>
{
b.HasOne("Flaro.Core.Library.Movie", null)
.WithMany()
.HasForeignKey("MovieId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Flaro.Core.Library.Person", null)
.WithMany()
.HasForeignKey("PersonId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Flaro.Core.Library.Series", null)
.WithMany()
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Flaro.Core.Library.Episode", b =>
{
b.HasOne("Flaro.Core.Library.Series", null)
.WithMany()
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.GenreLink", b =>
{
b.HasOne("Flaro.Core.Library.Genre", null)
.WithMany()
.HasForeignKey("GenreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Flaro.Core.Library.Movie", null)
.WithMany()
.HasForeignKey("MovieId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Flaro.Core.Library.Series", null)
.WithMany()
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Flaro.Core.Library.MediaFile", b =>
{
b.HasOne("Flaro.Core.Libraries.Library", null)
.WithMany()
.HasForeignKey("LibraryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.MediaStream", b =>
{
b.HasOne("Flaro.Core.Library.MediaFile", null)
.WithMany()
.HasForeignKey("MediaFileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.Movie", b =>
{
b.HasOne("Flaro.Core.Libraries.Library", null)
.WithMany()
.HasForeignKey("LibraryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.Series", b =>
{
b.HasOne("Flaro.Core.Libraries.Library", null)
.WithMany()
.HasForeignKey("LibraryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b =>
{
b.HasOne("Flaro.Core.Library.Episode", null)
.WithMany()
.HasForeignKey("EpisodeId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Flaro.Core.Library.Movie", null)
.WithMany()
.HasForeignKey("MovieId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Flaro.Core.Library.Series", null)
.WithMany()
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Flaro.Core.Users.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Flaro.Data.Migrations
{
/// <inheritdoc />
public partial class Kenntnisnahme : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<long>(
name: "ChangedSince",
table: "Series",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<long>(
name: "ChangedSince",
table: "Movies",
type: "INTEGER",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ChangedSince",
table: "Series");
migrationBuilder.DropColumn(
name: "ChangedSince",
table: "Movies");
}
}
}
@@ -455,6 +455,9 @@ namespace Flaro.Data.Migrations
b.Property<bool>("BackdropPinned") b.Property<bool>("BackdropPinned")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<long?>("ChangedSince")
.HasColumnType("INTEGER");
b.Property<string>("ContentRating") b.Property<string>("ContentRating")
.HasMaxLength(16) .HasMaxLength(16)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@@ -587,6 +590,9 @@ namespace Flaro.Data.Migrations
b.Property<bool>("BackdropPinned") b.Property<bool>("BackdropPinned")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<long?>("ChangedSince")
.HasColumnType("INTEGER");
b.Property<string>("ContentRating") b.Property<string>("ContentRating")
.HasMaxLength(16) .HasMaxLength(16)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
+43 -3
View File
@@ -112,9 +112,24 @@ public class FreigabeTests
Assert.That(await Titel(gast), Is.EqualTo(new[] { "Geheimsache" })); Assert.That(await Titel(gast), Is.EqualTo(new[] { "Geheimsache" }));
} }
/// <summary>Ein Zeitpunkt in der Zukunft hält ihn zurück — ohne Hintergrunddienst.</summary> /// <summary>Ein geplanter Titel ist sichtbar, aber noch nicht abspielbar.</summary>
///
/// <remarks>
/// <para>
/// <b>Zwei Zustände, nicht einer.</b> Ohne Zeitpunkt wartet ein Titel auf
/// eine Entscheidung — den soll niemand sehen. Mit einem Zeitpunkt in der
/// Zukunft ist die Entscheidung gefallen: er kommt, nur noch nicht. Ihn
/// dann zu verstecken hiesse, die Vorfreude zu unterschlagen, die der Sinn
/// des Planens ist.
/// </para>
/// <para>
/// <b>Sichtbar heisst aber nicht abspielbar</b>, und das ist der Teil, der
/// wirklich zählt: ohne den Riegel bei der Auslieferung wäre „Verfügbar ab
/// …" eine Zierde, und die Datei ginge trotzdem heraus.
/// </para>
/// </remarks>
[Test] [Test]
public async Task Ein_geplanter_Titel_erscheint_erst_zu_seiner_Zeit() public async Task Ein_geplanter_Titel_ist_sichtbar_aber_nicht_abspielbar()
{ {
Datei("Geheimsache (2024).mkv"); Datei("Geheimsache (2024).mkv");
@@ -132,7 +147,28 @@ public class FreigabeTests
// Auf morgen gelegt. // Auf morgen gelegt.
await alsAdmin.PostAsJsonAsync("/api/v1/review/publish", await alsAdmin.PostAsJsonAsync("/api/v1/review/publish",
new FreigabeRequest(null, DateTimeOffset.UtcNow.AddDays(1))); new FreigabeRequest(null, DateTimeOffset.UtcNow.AddDays(1)));
Assert.That(await Titel(gast), Is.Empty, "Ein geplanter Titel ist noch nicht sichtbar.");
Assert.That(await Titel(gast), Is.EqualTo(new[] { "Geheimsache" }),
"Ein geplanter Titel steht in der Mediathek — dafür plant man ihn.");
// **Aber die Datei geht nicht heraus.** Das ist der Teil, der zählt.
var filme = await gast.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies");
var datei = filme!.Items.Single().Files.Single();
var geladen = await gast.GetAsync($"/api/v1/files/{datei.Id}/download");
var gestroemt = await gast.PostAsJsonAsync($"/api/v1/files/{datei.Id}/stream",
new StreamRequest(["mp4"], ["h264"], ["aac"]));
Assert.Multiple(() =>
{
Assert.That(geladen.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden));
Assert.That(gestroemt.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden));
});
// **Auch für Administratoren.** Eine Hintertür für eine Rolle wäre
// eine Regel, die manchmal gilt; wer vorziehen will, hat den Knopf.
var alsAdminGeladen = await alsAdmin.GetAsync($"/api/v1/files/{datei.Id}/download");
Assert.That(alsAdminGeladen.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden));
// Und er steht weiterhin in der Prüfliste — mit seinem Termin. // Und er steht weiterhin in der Prüfliste — mit seinem Termin.
var wartend = await alsAdmin.GetFromJsonAsync<List<Pruefeintrag>>("/api/v1/review"); var wartend = await alsAdmin.GetFromJsonAsync<List<Pruefeintrag>>("/api/v1/review");
@@ -143,6 +179,10 @@ public class FreigabeTests
await alsAdmin.PostAsJsonAsync("/api/v1/review/publish", await alsAdmin.PostAsJsonAsync("/api/v1/review/publish",
new FreigabeRequest(null, DateTimeOffset.UtcNow.AddDays(-1))); new FreigabeRequest(null, DateTimeOffset.UtcNow.AddDays(-1)));
Assert.That(await Titel(gast), Is.EqualTo(new[] { "Geheimsache" })); Assert.That(await Titel(gast), Is.EqualTo(new[] { "Geheimsache" }));
var jetztGeladen = await gast.GetAsync($"/api/v1/files/{datei.Id}/download");
Assert.That(jetztGeladen.StatusCode, Is.Not.EqualTo(HttpStatusCode.Forbidden),
"ab dem Termin geht die Datei heraus");
} }
/// <summary>Ohne Prüfung erscheint alles sofort — das ist die Vorgabe.</summary> /// <summary>Ohne Prüfung erscheint alles sofort — das ist die Vorgabe.</summary>
+14
View File
@@ -15,12 +15,16 @@ export type Spur = {
channels: number | null; width: number | null; height: number | null; channels: number | null; width: number | null; height: number | null;
/** Die fertige Zeile vom Server, etwa Deutsch · Dolby Digital Plus · 5.1. */ /** Die fertige Zeile vom Server, etwa Deutsch · Dolby Digital Plus · 5.1. */
display: string | null; display: string | null;
/** Die Sprache als Wort — „Deutsch" statt „deu". */
languageName: string | null;
}; };
export type Datei = { export type Datei = {
id: string; fileName: string; sizeBytes: number; id: string; fileName: string; sizeBytes: number;
container: string | null; durationSeconds: number | null; streams: Spur[]; container: string | null; durationSeconds: number | null; streams: Spur[];
/** „Mobil", „4K", „Director's Cut" — oder `null`, wenn es nur eine gibt. */ /** „Mobil", „4K", „Director's Cut" — oder `null`, wenn es nur eine gibt. */
versionLabel: string | null; versionLabel: string | null;
/** Der Ordner unterhalb der Mediathek — nur für Administratoren gefüllt. */
folder: string | null;
}; };
/** Was dieser Benutzer mit einem Titel zu tun hatte. */ /** Was dieser Benutzer mit einem Titel zu tun hatte. */
export type Stand = { export type Stand = {
@@ -698,6 +702,8 @@ export const bildHochladen = async (
}; };
export type Pruefeintrag = { export type Pruefeintrag = {
/** „neu" (wartet auf Freigabe) oder „geaendert" (nur zur Kenntnis). */
grund?: 'neu' | 'geaendert';
kind: 'movie' | 'series'; kind: 'movie' | 'series';
id: string; title: string; year: number | null; id: string; title: string; year: number | null;
posterImage: string | null; posterImage: string | null;
@@ -713,6 +719,14 @@ export type Pruefeintrag = {
export const zuPruefen = () => anfrage<Pruefeintrag[]>('/review'); export const zuPruefen = () => anfrage<Pruefeintrag[]>('/review');
/** Hakt geänderte Titel ab — ohne Kennung alle.
*
* **Nicht dasselbe wie veröffentlichen.** Diese Titel stehen längst in der
* Mediathek; es gibt nichts freizugeben, nur etwas zur Kenntnis zu nehmen. */
export const zurKenntnis = (ids?: string[]) =>
anfrage<{ acknowledged: number }>('/review/acknowledge',
{ method: 'POST', body: JSON.stringify({ ids: ids ?? null, publishAt: null }) });
/** Ohne Kennungen: alles, was noch nicht sichtbar ist. Ohne Zeitpunkt: jetzt. */ /** Ohne Kennungen: alles, was noch nicht sichtbar ist. Ohne Zeitpunkt: jetzt. */
export const veroeffentlichen = (ids?: string[], publishAt?: string) => export const veroeffentlichen = (ids?: string[], publishAt?: string) =>
anfrage<{ published: number }>('/review/publish', anfrage<{ published: number }>('/review/publish',
+3
View File
@@ -71,6 +71,9 @@ export const icons = {
regler: rahmen('<path d="M4 7h7M15 7h5M4 17h3M11 17h9"/>' regler: rahmen('<path d="M4 7h7M15 7h5M4 17h3M11 17h9"/>'
+ '<circle cx="13" cy="7" r="2.1"/><circle cx="9" cy="17" r="2.1"/>'), + '<circle cx="13" cy="7" r="2.1"/><circle cx="9" cy="17" r="2.1"/>'),
/** Eine Uhr — für alles, was erst später gilt. */
uhr: rahmen('<circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 1.8"/>'),
/** Den Spieler auf die Leiste unten schrumpfen. */ /** Den Spieler auf die Leiste unten schrumpfen. */
verkleinern: rahmen('<path d="M5 9.5 12 16l7-6.5"/>'), verkleinern: rahmen('<path d="M5 9.5 12 16l7-6.5"/>'),
+26
View File
@@ -1626,3 +1626,29 @@ body:has(.auswahlleiste:not(.hidden)) .hinweise { bottom: 104px; }
100% { background: transparent; } 100% { background: transparent; }
} }
@media (prefers-reduced-motion: reduce) { .hervor { animation: none; } } @media (prefers-reduced-motion: reduce) { .hervor { animation: none; } }
/* Der Ordner in den Medienangaben — schmaler gesetzt, er ist ein Pfad. */
.medientabelle dd.pfad {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px; color: var(--text-dim);
overflow-wrap: anywhere;
}
/* Die Überschrift der zweiten Gruppe in der Prüfliste. */
.pruef-gruppe {
display: flex; align-items: center; gap: var(--s-regular);
flex-wrap: wrap;
padding: var(--s-regular) var(--s-snug) var(--s-snug);
border-top: 1px solid var(--line); margin-top: var(--s-snug);
}
.pruef-gruppe strong { font-size: 14px; }
.pruef-gruppe .winzig { flex: 1 1 240px; }
/* „Verfügbar ab …" steht dort, wo sonst Abspielen stünde. */
.verfuegbar-ab {
display: inline-flex; align-items: center; gap: var(--s-snug);
padding: 10px 16px; border-radius: var(--r-control);
background: var(--raised); border: 1px solid var(--line);
font-size: 14px; font-weight: 600; color: var(--text-dim);
}
.verfuegbar-ab .icon svg { width: 18px; height: 18px; }
+47 -6
View File
@@ -3,7 +3,7 @@ import {
scanStarten, scanstand, metadatenAuffrischen, doppelteZusammenlegen, scanStarten, scanstand, metadatenAuffrischen, doppelteZusammenlegen,
vermisste, vermisstesEntfernen, type VermissteDatei, vermisste, vermisstesEntfernen, type VermissteDatei,
protokolltage, protokollAdresse, sprachen, spracheSetzen, uebersetzungenHolen, protokolltage, protokollAdresse, sprachen, spracheSetzen, uebersetzungenHolen,
meineSprache, zuPruefen, veroeffentlichen, zurueckziehen, pruefungSetzen, meineSprache, zuPruefen, veroeffentlichen, zurueckziehen, pruefungSetzen, zurKenntnis,
mediathekZuruecksetzen, mediathekZuruecksetzen,
type Pruefeintrag, type Pruefeintrag,
benutzer, benutzerAnlegen, metadatenEinstellung, metadatenSetzen, benutzer, benutzerAnlegen, metadatenEinstellung, metadatenSetzen,
@@ -290,17 +290,48 @@ function pruefbereich(geaendert: () => void): {
freigeben.textContent = `Veröffentlichen (${wieViele})`; freigeben.textContent = `Veröffentlichen (${wieViele})`;
liste.innerHTML = ''; liste.innerHTML = '';
stand.forEach(e => liste.appendChild(zeileFuer(e)));
// **Zwei Gruppen, zwei Bedeutungen.** Ein neuer Fund wartet auf Freigabe
// und ist bis dahin unsichtbar; ein geänderter Titel steht längst in der
// Mediathek und soll nur nicht unbemerkt anders heissen. Beides in einer
// Reihe hiesse, „Veröffentlichen" auf etwas anzubieten, das veröffentlicht
// ist.
const neue = stand.filter(e => e.grund !== 'geaendert');
const geaenderte = stand.filter(e => e.grund === 'geaendert');
neue.forEach(e => liste.appendChild(zeileFuer(e)));
if (geaenderte.length > 0) {
const kopf = document.createElement('div');
kopf.className = 'pruef-gruppe';
kopf.innerHTML = '<strong>Geändert</strong>'
+ '<span class="winzig">Diese Titel sind schon sichtbar. Ein Auffrischen '
+ 'hat Titel, Jahr oder Plakat geändert — hier nur zur Kenntnis.</span>';
const abhaken = knopf('Alle zur Kenntnis', 'btn');
abhaken.onclick = async () => {
abhaken.disabled = true;
try { await zurKenntnis(); await laden(); }
catch (fehler) { liste.prepend(fehlerzeile(meldung(fehler))); abhaken.disabled = false; }
};
kopf.appendChild(abhaken);
liste.append(kopf, ...geaenderte.map(zeileFuer));
}
} }
function zeileFuer(e: Pruefeintrag): HTMLElement { function zeileFuer(e: Pruefeintrag): HTMLElement {
const d = document.createElement('div'); const d = document.createElement('div');
d.className = 'einstellung pruefzeile'; d.className = 'einstellung pruefzeile';
const geaendertes = e.grund === 'geaendert';
const haken = document.createElement('input'); const haken = document.createElement('input');
haken.type = 'checkbox'; haken.type = 'checkbox';
haken.checked = gewaehlt.has(e.id); haken.checked = gewaehlt.has(e.id);
haken.setAttribute('aria-label', `„${e.title}“ auswählen`); haken.setAttribute('aria-label', `„${e.title}“ auswählen`);
// Die Sammelaktionen oben veröffentlichen — auf Geändertes passt das nicht.
haken.classList.toggle('hidden', geaendertes);
haken.onchange = () => { haken.onchange = () => {
if (haken.checked) gewaehlt.add(e.id); else gewaehlt.delete(e.id); if (haken.checked) gewaehlt.add(e.id); else gewaehlt.delete(e.id);
zeichnen(); zeichnen();
@@ -323,7 +354,9 @@ function pruefbereich(geaendert: () => void): {
const was = e.kind === 'series' const was = e.kind === 'series'
? `${e.count} ${e.count === 1 ? 'Folge' : 'Folgen'}` ? `${e.count} ${e.count === 1 ? 'Folge' : 'Folgen'}`
: null; : null;
const lage = geplant const lage = geaendertes
? 'Sichtbar — ein Auffrischen hat etwas geändert'
: geplant
? `Geplant für ${geplant.toLocaleString('de-CH', { dateStyle: 'medium', timeStyle: 'short' })}` ? `Geplant für ${geplant.toLocaleString('de-CH', { dateStyle: 'medium', timeStyle: 'short' })}`
: e.kind === 'series' && e.count > 1 && !e.publishAt : e.kind === 'series' && e.count > 1 && !e.publishAt
? 'Wartet' ? 'Wartet'
@@ -335,11 +368,19 @@ function pruefbereich(geaendert: () => void): {
text.innerHTML = `<strong>${esc(e.title)}${e.year ? ` (${e.year})` : ''}</strong>` text.innerHTML = `<strong>${esc(e.title)}${e.year ? ` (${e.year})` : ''}</strong>`
+ `<span class="winzig">${[was, lage, hinweis].filter(Boolean).map(esc).join(' · ')}</span>`; + `<span class="winzig">${[was, lage, hinweis].filter(Boolean).map(esc).join(' · ')}</span>`;
const nur = knopf(geplant ? 'Jetzt veröffentlichen' : 'Veröffentlichen', 'btn'); // **Geändertes wird abgehakt, nicht veröffentlicht** — es ist schon
// sichtbar. „Veröffentlichen" wäre dort ein Knopf, der nichts tut.
const nur = knopf(
geaendertes ? 'Zur Kenntnis' : geplant ? 'Jetzt veröffentlichen' : 'Veröffentlichen',
'btn');
nur.onclick = async () => { nur.onclick = async () => {
nur.disabled = true; nur.disabled = true;
try { await veroeffentlichen(e.ids); await laden(); geaendert(); } try {
catch (fehler) { d.appendChild(fehlerzeile(meldung(fehler))); nur.disabled = false; } if (geaendertes) await zurKenntnis([e.id]);
else await veroeffentlichen(e.ids);
await laden();
geaendert();
} catch (fehler) { d.appendChild(fehlerzeile(meldung(fehler))); nur.disabled = false; }
}; };
// **Dasselbe Dreipunktemenü wie auf einer Kachel.** // **Dasselbe Dreipunktemenü wie auf einer Kachel.**
+82 -22
View File
@@ -239,7 +239,25 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
const hinweisfeld = document.createElement('div'); const hinweisfeld = document.createElement('div');
hinweisfeld.className = 'hinweis'; hinweisfeld.className = 'hinweis';
if (datei) { // **Geplant heisst sichtbar, nicht abspielbar.**
//
// Der Titel steht in der Mediathek, damit man weiss, dass er kommt — das
// ist der Sinn des Planens. Abspielen und Herunterladen gibt es erst ab
// dem Termin, und der Server lehnt sie bis dahin auch ab; stünden die
// Knöpfe trotzdem da, wäre die Angabe eine Zierde.
const geplantAb = eintrag.publishAt ? new Date(eintrag.publishAt) : null;
const nochNicht = geplantAb !== null && geplantAb > new Date();
if (nochNicht) {
const hinweisZeile = document.createElement('div');
hinweisZeile.className = 'verfuegbar-ab';
hinweisZeile.append(icon('uhr'), document.createTextNode(
`Verfügbar ab ${geplantAb!.toLocaleString('de-CH',
{ dateStyle: 'long', timeStyle: 'short' })}`));
reihe.appendChild(hinweisZeile);
}
if (datei && !nochNicht) {
const stelle = stand?.positionSeconds ?? 0; const stelle = stand?.positionSeconds ?? 0;
const los = knopf(stelle > 0 ? `Fortsetzen ab ${dauer(stelle)}` : 'Abspielen', 'btn btn-primary'); const los = knopf(stelle > 0 ? `Fortsetzen ab ${dauer(stelle)}` : 'Abspielen', 'btn btn-primary');
los.prepend(icon('abspielen')); los.prepend(icon('abspielen'));
@@ -305,6 +323,19 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
/* ---------- Medienangaben ---------- */ /* ---------- Medienangaben ---------- */
/** Video / Ton / Untertitel — Plex' Tabelle unter der Beschreibung. */ /** Video / Ton / Untertitel — Plex' Tabelle unter der Beschreibung. */
/** Was über einer Datei steht.
*
* **Zwei Leserschaften, zwei Bedürfnisse.**
*
* Wer einen Film sucht, will wissen: wie lang, in welcher Qualität, in
* welchen Sprachen — Ton wie Untertitel. Dateiformat, Grösse und Ordner
* sagen ihm nichts und gehen ihn auch nichts an: der Pfad verriete den
* Aufbau des Servers.
*
* Wer die Mediathek betreut, braucht genau das Gegenteil. Deshalb stehen
* die technischen Angaben darunter und nur für Administratoren — der Ordner
* kommt ohnehin nur dann vom Server.
*/
function medienangaben(datei: Datei): HTMLElement { function medienangaben(datei: Datei): HTMLElement {
const liste = document.createElement('dl'); const liste = document.createElement('dl');
liste.className = 'medientabelle'; liste.className = 'medientabelle';
@@ -313,19 +344,59 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
const ton = datei.streams.filter(s => s.kind === 'Audio'); const ton = datei.streams.filter(s => s.kind === 'Audio');
const text = datei.streams.filter(s => s.kind === 'Subtitle'); const text = datei.streams.filter(s => s.kind === 'Subtitle');
zeile('Datei', [datei.container?.toUpperCase(), groesse(datei.sizeBytes), /* ---------- Für alle ---------- */
dauer(datei.durationSeconds)].filter(Boolean).join(' · '));
// **Auch hier die Formulierung vom Server.** Vorher baute diese Zeile if (datei.durationSeconds) zeile('Laufzeit', laufzeit(datei.durationSeconds));
// ihren Text selbst — „240p (H264)" statt „240p · H.264" —, und damit
// stand in derselben Tabelle zweierlei Schreibweise untereinander.
if (bild) { if (bild) {
const v = zeile('Video', spurname(bild)); // Nur die Stufe, nicht der Codec: „H.264" ist für den, der schauen
// Die gemessene Höhe gehört in den Tooltip: 1040 ist keine Stufe, // will, kein Unterschied, den er trifft.
// sondern ein beschnittenes Bild. const stufe = bild.display?.split(' · ')[0] ?? null;
if (stufe) {
const v = zeile('Auflösung', stufe);
// Die gemessene Grösse gehört in den Tooltip: 1920×800 ist kein
// Fehler, sondern Kinoformat.
if (bild.width && bild.height) v.title = `${bild.width} × ${bild.height}`; if (bild.width && bild.height) v.title = `${bild.width} × ${bild.height}`;
} }
if (ton.length) zeile('Ton', ton.map(spurname).join(' · ')); }
if (text.length) zeile('Untertitel', text.map(spurname).join(' · '));
// **Nur die Sprachen, und jede einmal.** Vorher stand hier „Englisch ·
// Englisch · Englisch · Englisch · Deutsch · …", weil jede Spur einzeln
// aufgezählt wurde — bei sieben Tonspuren eine Zeile, die niemand liest.
if (ton.length) zeile('Ton', sprachen(ton));
if (text.length) zeile('Untertitel', sprachen(text));
/* ---------- Nur für die Verwaltung ---------- */
if (istAdmin()) {
const technisch = [datei.container?.toUpperCase(), groesse(datei.sizeBytes)]
.filter(Boolean).join(' · ');
if (technisch) {
const d = zeile('Datei', technisch);
d.title = datei.fileName;
}
// 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 — ohne Umweg über den Zuordnungsdialog.
if (datei.folder) zeile('Ordner', datei.folder).className = 'pfad';
}
/** Die Sprachen einer Spurenliste — jede einmal, in ihrer Reihenfolge. */
function sprachen(spuren: Datei['streams']): string {
const namen: string[] = [];
spuren.forEach(s => {
const name = s.languageName ?? s.display?.split(' · ')[0] ?? null;
if (name && !namen.includes(name)) namen.push(name);
});
return namen.length > 0 ? namen.join(' · ') : `${spuren.length} Spuren`;
}
/** „2 Std. 25 Min." — nicht „2:25:01". */
function laufzeit(sekunden: number): string {
const m = Math.round(sekunden / 60);
const h = Math.floor(m / 60);
return h > 0 ? `${h} Std. ${m % 60} Min.` : `${m} Min.`;
}
function zeile(name: string, wert: string): HTMLElement { function zeile(name: string, wert: string): HTMLElement {
const dt = document.createElement('dt'); const dt = document.createElement('dt');
@@ -339,17 +410,6 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
return liste; return liste;
} }
/** Der Name einer Spur — vom Server, nicht hier zusammengesetzt.
*
* **Vorher stand hier „DEU EAC3 6.0".** Jedes Stück davon war richtig und
* keines verständlich: EAC3 heisst Dolby Digital Plus, und sechs Kanäle
* sind 5.1, nicht 6.0. Der Server formuliert das jetzt, weil die Apps
* denselben Satz brauchen — zweimal formuliert driftet er auseinander. */
function spurname(s: Datei['streams'][number]): string {
if (s.display) return s.display;
// Rückfall für einen älteren Server, der das Feld noch nicht schickt.
return [s.language?.toUpperCase(), s.codec?.toUpperCase()].filter(Boolean).join(' ');
}
/* ---------- Besetzung ---------- */ /* ---------- Besetzung ---------- */