Spider-Man ist nicht 720p, er ist breit

Die Auflösung wurde allein aus der Höhe gebildet. Ein Kinoformat von 2.39:1
liegt als 1920×800 vor — nach der Höhe gerechnet kam „720p" heraus, und das
stand so in der Oberfläche. Jetzt zählt die grössere von Breite und Höhe;
Hochformat kippt damit nicht, und 4K-Breitbild heisst nicht mehr 1440p.

Die Detailansicht hat zwei Leserschaften, und bisher bekamen beide dasselbe.
Wer schauen will, sieht jetzt Laufzeit, Auflösung und die Sprachen — jede
einmal, statt „Englisch · Englisch · Englisch · Englisch · Deutsch". Wer die
Mediathek betreut, sieht darunter Dateiformat, Grösse und den Ordner. Der
Ordner verrät eine falsche Zuordnung in einer Sekunde: steht über der
Beschreibung „Hanni & Nanni 3" und der Ordner heisst „Hanni und Nanni", ist
die Sache klar. Er kommt nur für Administratoren überhaupt vom Server —
versteckt im Client wäre er weiterhin auslesbar.

Ein geplanter Titel war unsichtbar. Das ist die Hälfte, die nichts bringt:
dass er kommt, ist der Sinn des Planens. Er steht jetzt mit „Verfügbar ab …"
in der Mediathek, und die Datei geht trotzdem nicht heraus — Download wie
Strom lehnen bis zum Termin ab, auch für Administratoren. Wer vorziehen will,
hat „Jetzt veröffentlichen"; eine Hintertür für eine Rolle wäre eine Regel,
die manchmal gilt.

Was ein Auffrischen geändert hat, steht jetzt unter „Zu prüfen" — in einer
eigenen Gruppe mit „Zur Kenntnis" statt „Veröffentlichen". Diese Titel sind
sichtbar; es gibt nichts freizugeben, nur etwas abzuhaken.

Und das Protokoll sagt endlich, wo es knallt. „InvalidOperationException:
Nullable object must have a value." ohne eine einzige Zeile Herkunft ist für
niemanden eine Diagnose — weder für den Betreiber noch für mich.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-06 14:51:00 +02:00
co-authored by Claude Opus 5
parent f44779c899
commit 255eb7c2f0
19 changed files with 1569 additions and 64 deletions
+42 -3
View File
@@ -194,7 +194,19 @@ public sealed record Pruefeintrag(
/// <summary>Wie viele Titel dahinterstecken — bei einer Serie die Zahl der Folgen.</summary>
int Count,
/// <summary>Worauf eine Freigabe wirkt.</summary>
IReadOnlyList<Guid> Ids);
IReadOnlyList<Guid> Ids,
/// <summary>Warum der Eintrag hier steht: <c>neu</c> oder <c>geaendert</c>.</summary>
///
/// <remarks>
/// <b>Zwei verschiedene Dinge in einer Liste, und das mit Absicht.</b> Ein
/// neuer Fund wartet auf Freigabe und ist bis dahin unsichtbar. Ein
/// geänderter Titel steht längst in der Mediathek — er soll nur nicht
/// unbemerkt anders heissen oder ein anderes Plakat tragen. Beides gehört
/// vor dieselben Augen, aber nicht unter denselben Knopf: das eine wird
/// veröffentlicht, das andere nur zur Kenntnis genommen.
/// </remarks>
string Grund = "neu");
public sealed record FreigabeRequest(
IReadOnlyList<Guid>? Ids,
@@ -256,7 +268,17 @@ public sealed record MediaStreamResponse(
/// dieselbe Tonspur am Fernseher anders als im Browser. Die Rohwerte
/// stehen weiterhin daneben; wer sie braucht, hat sie.
/// </remarks>
string? Display = null);
string? Display = null,
/// <summary>Die Sprache als Wort — „Deutsch" statt „deu".</summary>
///
/// <remarks>
/// <b>Damit eine Liste von Sprachen ohne Rätselraten entsteht.</b> Die
/// Detailansicht nennt, in welchen Sprachen ein Film vorliegt; sie dafür
/// aus <see cref="Display"/> herauszuschneiden hiesse, sich auf dessen
/// Satzbau zu verlassen.
/// </remarks>
string? LanguageName = null);
/// <summary>Eine Datei hinter einem Titel — eine Fassung also.</summary>
///
@@ -272,9 +294,26 @@ public sealed record MediaStreamResponse(
/// derselben Folge als zwei gleich aussehende Knopfreihen untereinander, und
/// die Folge wirkte doppelt.
/// </param>
/// <param name="Folder">
/// <b>Der Ordner unterhalb der Mediathek</b> — <c>null</c> für alle, die nicht
/// verwalten dürfen.
///
/// <para>
/// <b>Weil man einer falschen Zuordnung am Ordner sofort ansieht, dass sie
/// falsch ist.</b> Steht über der Beschreibung „Hanni &amp; Nanni 3" und der
/// Ordner heisst <c>Hanni und Nanni</c>, ist die Sache in einer Sekunde klar
/// — ohne Umweg über den Zuordnungsdialog.
/// </para>
/// <para>
/// <b>Nur der Teil unterhalb der Mediathek, und nur für Administratoren.</b>
/// Der volle Pfad verriete jedem Angemeldeten den Aufbau des Servers; der
/// relative sagt genau das, was zum Beurteilen nötig ist.
/// </para>
/// </param>
public sealed record MediaFileResponse(
Guid Id, string FileName, long SizeBytes, string? Container, double? DurationSeconds,
IReadOnlyList<MediaStreamResponse> Streams, string? VersionLabel = null);
IReadOnlyList<MediaStreamResponse> Streams, string? VersionLabel = null,
string? Folder = null);
public sealed record MovieResponse(
Guid Id, string Title, int? Year, string? Overview,
@@ -25,11 +25,13 @@ public static class DeliveryEndpoints
// durch bis zum SPA-Rückfall und bekäme die Webseite mit Status 200.
// Genau das ist passiert: der Spieler bekam HTML statt Video.
app.MapMethods("/api/v1/files/{id:guid}/download", ["GET", "HEAD"], async (
Guid id, FlaroContext db, PackageCoordinator packer) =>
Guid id, FlaroContext db, PackageCoordinator packer, TimeProvider uhr) =>
{
var gefunden = await Laden(db, id);
if (gefunden is not var (datei, plan)) return Results.NotFound();
if (await FreiAb(db, datei, uhr.GetUtcNow()) is { } nochNicht) return NochNicht(nochNicht);
// Passt schon alles, geht die Datei unverändert hinaus — mit
// Längenangabe und Bereichsanfragen, also mit Fortschritt und
// Fortsetzen.
@@ -70,6 +72,41 @@ public static class DeliveryEndpoints
}).RequireAuthorization();
}
/// <summary>Ab wann diese Datei herausgehen darf — <c>null</c> heisst: sofort.</summary>
///
/// <remarks>
/// <para>
/// <b>Hier und nicht im Sichtbarkeitsfilter.</b> Ein geplanter Titel steht
/// mit „Verfügbar ab …" in der Mediathek — das ist der Sinn des Planens.
/// Sichtbar heisst aber nicht abspielbar: ohne diese Prüfung liesse sich
/// die Datei trotzdem herunterladen, und die Angabe wäre eine Zierde.
/// </para>
/// <para>
/// <b>Für alle, auch für Administratoren.</b> Wer die Freigabe vorziehen
/// will, hat dafür den Knopf „Jetzt veröffentlichen" — eine Hintertür für
/// eine Rolle wäre eine Regel, die manchmal gilt.
/// </para>
/// </remarks>
internal static async Task<DateTimeOffset?> FreiAb(
FlaroContext db, MediaFile datei, DateTimeOffset jetzt)
{
DateTimeOffset? wann = datei.MovieId is { } film
? await db.Movies.AsNoTracking().Where(m => m.Id == film)
.Select(m => m.PublishAt).FirstOrDefaultAsync()
: datei.EpisodeId is { } folge
? await db.Episodes.AsNoTracking().Where(f => f.Id == folge)
.Select(f => f.PublishAt).FirstOrDefaultAsync()
: null;
return wann is { } termin && termin > jetzt ? termin : null;
}
/// <summary>Der Satz, der statt der Datei kommt.</summary>
internal static IResult NochNicht(DateTimeOffset wann) =>
Results.Problem(
$"Dieser Titel ist ab {wann.ToLocalTime():dd.MM.yyyy, HH:mm} Uhr verfügbar.",
statusCode: StatusCodes.Status403Forbidden);
private static async Task<(MediaFile Datei, DeliveryPlan Plan)?> Laden(FlaroContext db, Guid id)
{
var datei = await db.MediaFiles.AsNoTracking().FirstOrDefaultAsync(f => f.Id == id);
@@ -21,12 +21,16 @@ public static class FreigabeEndpoints
var jetzt = uhr.GetUtcNow();
var filme = await db.Movies.AsNoTracking()
.Where(m => m.PublishAt == null || m.PublishAt > jetzt)
.Where(m => m.PublishAt == null || m.PublishAt > jetzt || m.ChangedSince != null)
.OrderBy(m => m.SortTitleValue)
.Select(m => new Pruefeintrag(
"movie", m.Id, m.Title, m.Year, m.PosterImage, m.PublishAt,
db.MediaFiles.Where(f => f.MovieId == m.Id).Select(f => f.Path).FirstOrDefault(),
m.TmdbId != null, 1, new List<Guid> { m.Id }))
m.TmdbId != null, 1, new List<Guid> { m.Id },
// **Neu sticht geändert.** Ein Titel, der noch gar nicht
// sichtbar ist, braucht eine Freigabe — dass sich dabei
// auch etwas geändert hat, ist die kleinere Nachricht.
m.PublishAt == null || m.PublishAt > jetzt ? "neu" : "geaendert"))
.ToListAsync(ct);
// **Folgen werden nach Serie gebündelt.** Zwanzig neue Folgen
@@ -71,13 +75,54 @@ public static class FreigabeEndpoints
.OrderBy(e => e.Title)
.ToList();
return Results.Ok(filme.Concat(gebuendelt).ToList());
// **Geänderte Serien stehen für sich.** Die Bündelung oben geht
// über wartende Folgen; eine Serie, deren Titel oder Plakat sich
// geändert hat, hat damit nichts zu tun — ihre Folgen sind längst
// sichtbar.
var schonDa = gebuendelt.Select(e => e.Id).ToHashSet();
var geaenderteSerien = await db.Series.AsNoTracking()
.Where(x => x.ChangedSince != null && !schonDa.Contains(x.Id))
.OrderBy(x => x.SortTitleValue)
.Select(x => new Pruefeintrag(
"series", x.Id, x.Title, x.Year, x.PosterImage, null,
null, x.TmdbId != null,
db.Episodes.Count(f => f.SeriesId == x.Id),
new List<Guid> { x.Id },
"geaendert"))
.ToListAsync(ct);
return Results.Ok(filme.Concat(gebuendelt).Concat(geaenderteSerien).ToList());
// Bei einer Serie sagt der Ordner mehr als die erste Folgendatei.
static string? Pfad(string? datei) =>
datei is null ? null : System.IO.Path.GetDirectoryName(datei);
});
// **Zur Kenntnis genommen.**
//
// Für Titel, die längst sichtbar sind und sich nur geändert haben.
// Veröffentlichen wäre dort das falsche Wort — es gibt nichts
// freizugeben, nur etwas abzuhaken.
gruppe.MapPost("/acknowledge", async (
FreigabeRequest anfrage, FlaroContext db, CancellationToken ct) =>
{
var ids = anfrage.Ids ?? [];
var filme = ids.Count == 0
? await db.Movies.Where(m => m.ChangedSince != null)
.ExecuteUpdateAsync(s => s.SetProperty(m => m.ChangedSince, (DateTimeOffset?)null), ct)
: await db.Movies.Where(m => ids.Contains(m.Id) && m.ChangedSince != null)
.ExecuteUpdateAsync(s => s.SetProperty(m => m.ChangedSince, (DateTimeOffset?)null), ct);
var serien = ids.Count == 0
? await db.Series.Where(x => x.ChangedSince != null)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.ChangedSince, (DateTimeOffset?)null), ct)
: await db.Series.Where(x => ids.Contains(x.Id) && x.ChangedSince != null)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.ChangedSince, (DateTimeOffset?)null), ct);
return Results.Ok(new { Acknowledged = filme + serien });
});
// Veröffentlichen oder planen. Ohne Zeitpunkt heisst: jetzt.
gruppe.MapPost("/publish", async (
FreigabeRequest anfrage, FlaroContext db, TimeProvider uhr, CancellationToken ct) =>
@@ -1,3 +1,4 @@
using Flaro.Api.Auth;
using Flaro.Core.Library;
using Flaro.Core.Media;
using System.Security.Claims;
@@ -90,21 +91,70 @@ public static class LibraryContentEndpoints
s.IsDefault, s.IsForced, s.Channels, s.Width, s.Height,
Display: s.Kind switch
{
MediaStreamKind.Video => Benennung.Bildspur(s.Codec, s.Height),
MediaStreamKind.Video => Benennung.Bildspur(s.Codec, s.Width, s.Height),
MediaStreamKind.Audio => Benennung.Tonspur(s.Language, s.Codec, s.Channels, s.Title),
_ => Benennung.Untertitelspur(s.Language, s.IsForced, fuerHoergeschaedigte: false),
}));
},
LanguageName: Benennung.Sprache(s.Language)));
}
private static MediaFileResponse Datei(
Guid id, string pfad, long bytes, string? schachtel, TimeSpan? dauer,
ILookup<Guid, MediaStreamResponse> spuren, string? fassung = null) =>
ILookup<Guid, MediaStreamResponse> spuren, string? fassung = null,
string? mediathekspfad = null) =>
new(id, System.IO.Path.GetFileName(pfad), bytes, schachtel, dauer?.TotalSeconds,
spuren[id].ToList(),
// Steht sie nicht in der Datenbank, wird sie aus dem Pfad gelesen:
// ein Bestand von vor dieser Änderung soll nicht erst neu gescannt
// werden müssen, um „Mobil" zu zeigen.
fassung ?? FileNames.Fassung(pfad));
fassung ?? FileNames.Fassung(pfad),
Ordner(pfad, mediathekspfad));
/// <summary>Der Pfad der Mediathek — nur für Administratoren.</summary>
///
/// <remarks>
/// <b>Hier entscheidet sich, wer den Ordner sieht.</b> Ohne Wurzel bleibt
/// <see cref="Ordner"/> bei <c>null</c>, und das Feld kommt erst gar nicht
/// in der Antwort an — statt es im Client zu verstecken, wo es jeder
/// auslesen könnte.
/// </remarks>
private static async Task<string?> MediathekspfadFuer(
FlaroContext db, ClaimsPrincipal wer, Guid bibliothek)
{
if (!wer.IsInRole(FlaroRoles.Administrator)) return null;
return await db.Libraries.AsNoTracking()
.Where(l => l.Id == bibliothek)
.Select(l => l.Path)
.FirstOrDefaultAsync();
}
/// <summary>Der Ordner unterhalb der Mediathek — oder <c>null</c>.</summary>
///
/// <remarks>
/// <b>Nur der Teil unterhalb, nie der volle Pfad.</b> Der verriete jedem
/// Angemeldeten den Aufbau des Servers; was zum Beurteilen einer Zuordnung
/// nötig ist, ist der Ordnername — „Hanni und Nanni" neben dem Titel
/// „Hanni &amp; Nanni 3" sagt in einer Sekunde, dass etwas nicht stimmt.
///
/// <c>null</c> für alle, die nicht verwalten dürfen: dort wird der
/// Mediathekspfad gar nicht erst hereingereicht.
/// </remarks>
private static string? Ordner(string pfad, string? mediathekspfad)
{
if (string.IsNullOrEmpty(mediathekspfad)) return null;
var ordner = System.IO.Path.GetDirectoryName(pfad);
if (string.IsNullOrEmpty(ordner)) return null;
var wurzel = mediathekspfad.TrimEnd('/', '\\');
if (!ordner.StartsWith(wurzel, StringComparison.Ordinal)) return null;
var darunter = ordner[wurzel.Length..].Trim('/', '\\');
// Liegt die Datei unmittelbar in der Mediathek, gibt es keinen Ordner
// — und „" wäre eine Zeile, die nichts sagt.
return darunter.Length > 0 ? darunter : null;
}
/// <summary>Die Genres eines Titels, in der Reihenfolge der Quelle.</summary>
private static async Task<IReadOnlyList<string>> GenresZu(FlaroContext db, Guid? film, Guid? serie) =>
@@ -559,6 +609,7 @@ public static class LibraryContentEndpoints
.ToListAsync();
var spuren = await SpurenZu(db, dateien.Select(f => f.Id).ToList());
var stände = await ZuständeZu(db, wer, filme: [id]);
var wurzel = await MediathekspfadFuer(db, wer, film.LibraryId);
var sprache = await SpracheVon(wer, db);
var uebersetzt = sprache == null ? null : await db.Translations.AsNoTracking()
@@ -567,7 +618,7 @@ public static class LibraryContentEndpoints
return Results.Ok(new MovieResponse(
film.Id, uebersetzt?.Title ?? film.Title, film.Year,
uebersetzt?.Overview ?? film.Overview, film.PosterImage, film.BackdropImage,
dateien.Select(f => Datei(f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, spuren, f.VersionLabel)).ToList(),
dateien.Select(f => Datei(f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, spuren, f.VersionLabel, wurzel)).ToList(),
stände.GetValueOrDefault(id),
film.LogoImage, film.RuntimeMinutes, film.Rating,
uebersetzt?.Tagline ?? film.Tagline, film.ContentRating,
@@ -671,6 +722,10 @@ public static class LibraryContentEndpoints
.Select(f => new { f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview, f.StillImage })
.ToListAsync();
var wurzel = await MediathekspfadFuer(db, wer,
await db.Series.AsNoTracking().Where(x => x.Id == id)
.Select(x => x.LibraryId).FirstAsync());
var ids = folgen.Select(f => f.Id).ToList();
var dateien = await db.MediaFiles.AsNoTracking()
.Where(d => d.EpisodeId != null && ids.Contains(d.EpisodeId!.Value))
@@ -687,7 +742,7 @@ public static class LibraryContentEndpoints
f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview,
f.StillImage ?? serienBild,
dateien.Where(d => d.EpisodeId == f.Id)
.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren, d.VersionLabel)).ToList(),
.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren, d.VersionLabel, wurzel)).ToList(),
stände.GetValueOrDefault(f.Id))).ToList();
return Results.Ok(new Page<EpisodeResponse>(items, items.Count));
@@ -32,10 +32,18 @@ public static class StreamEndpoints
// was der Benutzer gewählt hat.
app.MapPost("/api/v1/files/{id:guid}/stream", async (
Guid id, StreamRequest anfrage, ClaimsPrincipal wer,
Streamkoordinator koordinator, FlaroContext db) =>
Streamkoordinator koordinator, FlaroContext db, TimeProvider uhr) =>
{
if (PlaybackEndpoints.Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
// **Geplant heisst sichtbar, nicht abspielbar.** Ohne diese
// Prüfung liesse sich ein Titel, der mit „Verfügbar ab …" dasteht,
// trotzdem abspielen — und die Angabe wäre eine Zierde.
var gewuenschte = await db.MediaFiles.AsNoTracking().FirstOrDefaultAsync(f => f.Id == id);
if (gewuenschte is null) return Results.NotFound();
if (await DeliveryEndpoints.FreiAb(db, gewuenschte, uhr.GetUtcNow()) is { } nochNicht)
return DeliveryEndpoints.NochNicht(nochNicht);
var profil = Profil(anfrage);
var hoehe = anfrage.Height is { } gewuenscht
&& Stromentscheid.Stufen.Contains(gewuenscht) ? gewuenscht : (int?)null;
+41
View File
@@ -1,3 +1,4 @@
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using Flaro.Data;
@@ -76,16 +77,56 @@ public static partial class Fehlertext
{
var text = new StringBuilder();
var tiefe = 0;
Exception innerste = fehler;
for (Exception? e = fehler; e is not null; e = e.InnerException, tiefe++)
{
if (tiefe > 0) text.Append(" ← ");
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();
}
/// <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>
///
/// <remarks>
@@ -169,6 +169,24 @@ public sealed class Movie : Flaro.Core.Metadata.IBebildert
/// </remarks>
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>
///
/// <remarks>
@@ -350,6 +368,24 @@ public sealed class Series : Flaro.Core.Metadata.IBebildert
/// </remarks>
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>
///
/// <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
/// ein Fehler. Der genaue Wert gehört in den Tooltip, nicht in die Zeile.
/// </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,
>= 2000 => "2160p",
@@ -133,10 +171,13 @@ public static class Benennung
}
/// <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>();
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);
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.**
//
// Ein Titel ist sichtbar, wenn ein Zeitpunkt gesetzt ist und er
// erreicht wurde. 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>Zwei Zustände, nicht einer.</b> Ohne Zeitpunkt wartet ein Titel
// auf eine Entscheidung — den soll niemand sehen, er ist noch nicht
// einmal geprüft. Mit einem Zeitpunkt in der Zukunft ist die
// 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 =>
AuchUnveroeffentlichtes || (m.PublishAt != null && m.PublishAt <= Jetzt));
AuchUnveroeffentlichtes || m.PublishAt != null);
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
// Wahrheiten — eine veröffentlichte Serie, deren Folgen alle noch
// warten, wäre ein leerer Eintrag in der Mediathek.
b.Entity<Series>().HasQueryFilter(s =>
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 =>
{
+34 -4
View File
@@ -31,6 +31,7 @@ public sealed class MetadataEnricher(
IImageReader bildLeser,
ISidecarStore beiwerk,
Zusammenlegung zusammenlegung,
TimeProvider uhr,
ILogger<MetadataEnricher> log)
{
/// <param name="erneut">
@@ -66,11 +67,18 @@ public sealed class MetadataEnricher(
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;
try
{
filme = await FilmeZuordnen(libraryId, erneut, fortschritt, ct, nurSprache, nurDiese);
serien = await SerienZuordnen(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, pruefungNoetig);
folgen = await FolgenZuordnen(libraryId, erneut, fortschritt, ct, nurSprache, nurDiese);
}
catch (MetadataCredentialsException fehler)
@@ -88,7 +96,7 @@ public sealed class MetadataEnricher(
private async Task<int> FilmeZuordnen(
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
.Where(m => m.LibraryId == libraryId && (erneut || m.TmdbId == null)
@@ -190,6 +198,11 @@ public sealed class MetadataEnricher(
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;
// **Auch das Jahr, und zwar sofort.** Die beiden Tabellen müssen
// 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);
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);
}
@@ -299,7 +321,7 @@ public sealed class MetadataEnricher(
private async Task<int> SerienZuordnen(
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
.Where(s => s.LibraryId == libraryId && (erneut || s.TmdbId == null)
@@ -349,6 +371,8 @@ public sealed class MetadataEnricher(
}
vergeben[gefunden.ProviderId] = serie.Id;
var vorher = (serie.TmdbId, serie.Title, serie.PosterImage, serie.Year);
serie.TmdbId = gefunden.ProviderId;
serie.ImdbId = gefunden.ImdbId;
if (!serie.TitlePinned)
@@ -373,6 +397,12 @@ public sealed class MetadataEnricher(
await GenresSetzen(gefunden.Genres, 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;
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")
.HasColumnType("INTEGER");
b.Property<long?>("ChangedSince")
.HasColumnType("INTEGER");
b.Property<string>("ContentRating")
.HasMaxLength(16)
.HasColumnType("TEXT");
@@ -587,6 +590,9 @@ namespace Flaro.Data.Migrations
b.Property<bool>("BackdropPinned")
.HasColumnType("INTEGER");
b.Property<long?>("ChangedSince")
.HasColumnType("INTEGER");
b.Property<string>("ContentRating")
.HasMaxLength(16)
.HasColumnType("TEXT");
+43 -3
View File
@@ -112,9 +112,24 @@ public class FreigabeTests
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]
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");
@@ -132,7 +147,28 @@ public class FreigabeTests
// Auf morgen gelegt.
await alsAdmin.PostAsJsonAsync("/api/v1/review/publish",
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.
var wartend = await alsAdmin.GetFromJsonAsync<List<Pruefeintrag>>("/api/v1/review");
@@ -143,6 +179,10 @@ public class FreigabeTests
await alsAdmin.PostAsJsonAsync("/api/v1/review/publish",
new FreigabeRequest(null, DateTimeOffset.UtcNow.AddDays(-1)));
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>