diff --git a/server/src/Flaro.Api/Endpoints/Contracts.cs b/server/src/Flaro.Api/Endpoints/Contracts.cs
index 1f348a0..fb14537 100644
--- a/server/src/Flaro.Api/Endpoints/Contracts.cs
+++ b/server/src/Flaro.Api/Endpoints/Contracts.cs
@@ -194,7 +194,19 @@ public sealed record Pruefeintrag(
/// Wie viele Titel dahinterstecken — bei einer Serie die Zahl der Folgen.
int Count,
/// Worauf eine Freigabe wirkt.
- IReadOnlyList Ids);
+ IReadOnlyList Ids,
+
+ /// Warum der Eintrag hier steht: neu oder geaendert.
+ ///
+ ///
+ /// Zwei verschiedene Dinge in einer Liste, und das mit Absicht. 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.
+ ///
+ string Grund = "neu");
public sealed record FreigabeRequest(
IReadOnlyList? 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.
///
- string? Display = null);
+ string? Display = null,
+
+ /// Die Sprache als Wort — „Deutsch" statt „deu".
+ ///
+ ///
+ /// Damit eine Liste von Sprachen ohne Rätselraten entsteht. Die
+ /// Detailansicht nennt, in welchen Sprachen ein Film vorliegt; sie dafür
+ /// aus herauszuschneiden hiesse, sich auf dessen
+ /// Satzbau zu verlassen.
+ ///
+ string? LanguageName = null);
/// Eine Datei hinter einem Titel — eine Fassung also.
///
@@ -272,9 +294,26 @@ public sealed record MediaStreamResponse(
/// derselben Folge als zwei gleich aussehende Knopfreihen untereinander, und
/// die Folge wirkte doppelt.
///
+///
+/// Der Ordner unterhalb der Mediathek — null für alle, die nicht
+/// verwalten dürfen.
+///
+///
+/// Weil man einer falschen Zuordnung am Ordner sofort ansieht, dass sie
+/// falsch ist. Steht über der Beschreibung „Hanni & Nanni 3" und der
+/// Ordner heisst Hanni und Nanni, ist die Sache in einer Sekunde klar
+/// — ohne Umweg über den Zuordnungsdialog.
+///
+///
+/// Nur der Teil unterhalb der Mediathek, und nur für Administratoren.
+/// Der volle Pfad verriete jedem Angemeldeten den Aufbau des Servers; der
+/// relative sagt genau das, was zum Beurteilen nötig ist.
+///
+///
public sealed record MediaFileResponse(
Guid Id, string FileName, long SizeBytes, string? Container, double? DurationSeconds,
- IReadOnlyList Streams, string? VersionLabel = null);
+ IReadOnlyList Streams, string? VersionLabel = null,
+ string? Folder = null);
public sealed record MovieResponse(
Guid Id, string Title, int? Year, string? Overview,
diff --git a/server/src/Flaro.Api/Endpoints/DeliveryEndpoints.cs b/server/src/Flaro.Api/Endpoints/DeliveryEndpoints.cs
index 17d8f88..0901177 100644
--- a/server/src/Flaro.Api/Endpoints/DeliveryEndpoints.cs
+++ b/server/src/Flaro.Api/Endpoints/DeliveryEndpoints.cs
@@ -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();
}
+ /// Ab wann diese Datei herausgehen darf — null heisst: sofort.
+ ///
+ ///
+ ///
+ /// Hier und nicht im Sichtbarkeitsfilter. Ein geplanter Titel steht
+ /// mit „Verfügbar ab …" in der Mediathek — das ist der Sinn des Planens.
+ /// Sichtbar heisst aber nicht abspielbar: ohne diese Prüfung liesse sich
+ /// die Datei trotzdem herunterladen, und die Angabe wäre eine Zierde.
+ ///
+ ///
+ /// Für alle, auch für Administratoren. Wer die Freigabe vorziehen
+ /// will, hat dafür den Knopf „Jetzt veröffentlichen" — eine Hintertür für
+ /// eine Rolle wäre eine Regel, die manchmal gilt.
+ ///
+ ///
+ internal static async Task FreiAb(
+ FlaroContext db, MediaFile datei, DateTimeOffset jetzt)
+ {
+ DateTimeOffset? wann = datei.MovieId is { } film
+ ? await db.Movies.AsNoTracking().Where(m => m.Id == film)
+ .Select(m => m.PublishAt).FirstOrDefaultAsync()
+ : datei.EpisodeId is { } folge
+ ? await db.Episodes.AsNoTracking().Where(f => f.Id == folge)
+ .Select(f => f.PublishAt).FirstOrDefaultAsync()
+ : null;
+
+ return wann is { } termin && termin > jetzt ? termin : null;
+ }
+
+ /// Der Satz, der statt der Datei kommt.
+ internal static IResult NochNicht(DateTimeOffset wann) =>
+ Results.Problem(
+ $"Dieser Titel ist ab {wann.ToLocalTime():dd.MM.yyyy, HH:mm} Uhr verfügbar.",
+ statusCode: StatusCodes.Status403Forbidden);
+
private static async Task<(MediaFile Datei, DeliveryPlan Plan)?> Laden(FlaroContext db, Guid id)
{
var datei = await db.MediaFiles.AsNoTracking().FirstOrDefaultAsync(f => f.Id == id);
diff --git a/server/src/Flaro.Api/Endpoints/FreigabeEndpoints.cs b/server/src/Flaro.Api/Endpoints/FreigabeEndpoints.cs
index d774a88..e326795 100644
--- a/server/src/Flaro.Api/Endpoints/FreigabeEndpoints.cs
+++ b/server/src/Flaro.Api/Endpoints/FreigabeEndpoints.cs
@@ -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 { m.Id }))
+ m.TmdbId != null, 1, new List { 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 { 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) =>
diff --git a/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs b/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs
index 8bb8488..590337d 100644
--- a/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs
+++ b/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs
@@ -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 spuren, string? fassung = null) =>
+ ILookup 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));
+
+ /// Der Pfad der Mediathek — nur für Administratoren.
+ ///
+ ///
+ /// Hier entscheidet sich, wer den Ordner sieht. Ohne Wurzel bleibt
+ /// bei null, und das Feld kommt erst gar nicht
+ /// in der Antwort an — statt es im Client zu verstecken, wo es jeder
+ /// auslesen könnte.
+ ///
+ private static async Task 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();
+ }
+
+ /// Der Ordner unterhalb der Mediathek — oder null.
+ ///
+ ///
+ /// Nur der Teil unterhalb, nie der volle Pfad. Der verriete jedem
+ /// Angemeldeten den Aufbau des Servers; was zum Beurteilen einer Zuordnung
+ /// nötig ist, ist der Ordnername — „Hanni und Nanni" neben dem Titel
+ /// „Hanni & Nanni 3" sagt in einer Sekunde, dass etwas nicht stimmt.
+ ///
+ /// null für alle, die nicht verwalten dürfen: dort wird der
+ /// Mediathekspfad gar nicht erst hereingereicht.
+ ///
+ 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;
+ }
/// Die Genres eines Titels, in der Reihenfolge der Quelle.
private static async Task> 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(items, items.Count));
diff --git a/server/src/Flaro.Api/Endpoints/StreamEndpoints.cs b/server/src/Flaro.Api/Endpoints/StreamEndpoints.cs
index f559c32..0c7b4e7 100644
--- a/server/src/Flaro.Api/Endpoints/StreamEndpoints.cs
+++ b/server/src/Flaro.Api/Endpoints/StreamEndpoints.cs
@@ -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;
diff --git a/server/src/Flaro.Api/Fehlertext.cs b/server/src/Flaro.Api/Fehlertext.cs
index 461af10..96c0d1e 100644
--- a/server/src/Flaro.Api/Fehlertext.cs
+++ b/server/src/Flaro.Api/Fehlertext.cs
@@ -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();
}
+ /// Die Zeilen der Spur, die zu Flaro gehören.
+ ///
+ ///
+ /// Höchstens zwölf. Was darunter liegt, ist ASP.NET und EF Core —
+ /// bei einem Fehler im eigenen Code steht die Antwort immer oben.
+ ///
+ 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);
+ }
+
/// Welche Eindeutigkeitsregel gebrochen wurde — in Worten.
///
///
diff --git a/server/src/Flaro.Core/Library/MediaEntities.cs b/server/src/Flaro.Core/Library/MediaEntities.cs
index cc03991..dea5003 100644
--- a/server/src/Flaro.Core/Library/MediaEntities.cs
+++ b/server/src/Flaro.Core/Library/MediaEntities.cs
@@ -169,6 +169,24 @@ public sealed class Movie : Flaro.Core.Metadata.IBebildert
///
public bool SortTitlePinned { get; set; }
+ /// Seit wann eine Änderung auf ihre Kenntnisnahme wartet.
+ ///
+ ///
+ ///
+ /// Nicht dasselbe wie . 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.
+ ///
+ ///
+ /// null heisst: nichts offen. Gesetzt wird es nur, wenn die
+ /// Mediathek auf Prüfung steht — wer keine Prüfung will, soll auch keine
+ /// Liste bekommen.
+ ///
+ ///
+ public DateTimeOffset? ChangedSince { get; set; }
+
/// In welcher Sprache die Angaben geholt wurden.
///
///
@@ -350,6 +368,24 @@ public sealed class Series : Flaro.Core.Metadata.IBebildert
///
public bool SortTitlePinned { get; set; }
+ /// Seit wann eine Änderung auf ihre Kenntnisnahme wartet.
+ ///
+ ///
+ ///
+ /// Nicht dasselbe wie . 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.
+ ///
+ ///
+ /// null heisst: nichts offen. Gesetzt wird es nur, wenn die
+ /// Mediathek auf Prüfung steht — wer keine Prüfung will, soll auch keine
+ /// Liste bekommen.
+ ///
+ ///
+ public DateTimeOffset? ChangedSince { get; set; }
+
/// In welcher Sprache die Angaben geholt wurden.
///
///
diff --git a/server/src/Flaro.Core/Media/Benennung.cs b/server/src/Flaro.Core/Media/Benennung.cs
index fa0f8a0..07c1a44 100644
--- a/server/src/Flaro.Core/Media/Benennung.cs
+++ b/server/src/Flaro.Core/Media/Benennung.cs
@@ -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.
///
- public static string? Aufloesung(int? hoehe) => hoehe switch
+ public static string? Aufloesung(int? hoehe) => AusHoehe(hoehe);
+
+ /// Die Stufe aus Breite und Höhe — die grössere gewinnt.
+ ///
+ ///
+ ///
+ /// Die Höhe allein lügt bei Breitbild. „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.
+ ///
+ ///
+ /// Die grössere der beiden Stufen, nicht die Breite allein. Sonst
+ /// kippt es beim Hochformat: ein 1080×1920 gedrehtes Bild wäre nach der
+ /// Breite „720p". So stimmt beides, und keine Richtung ist bevorzugt.
+ ///
+ ///
+ 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;
+ }
+
+ /// Die Zeilenzahl, auf die eine Stufe lautet — zum Vergleichen.
+ 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
}
/// Die ganze Zeile zu einer Bildspur.
- public static string Bildspur(string? codec, int? hoehe)
+ public static string Bildspur(string? codec, int? hoehe) => Bildspur(codec, null, hoehe);
+
+ ///
+ public static string Bildspur(string? codec, int? breite, int? hoehe)
{
var teile = new List();
- 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";
}
diff --git a/server/src/Flaro.Data/FlaroContext.cs b/server/src/Flaro.Data/FlaroContext.cs
index 198d3df..d93c87f 100644
--- a/server/src/Flaro.Data/FlaroContext.cs
+++ b/server/src/Flaro.Data/FlaroContext.cs
@@ -278,22 +278,34 @@ public sealed class FlaroContext(DbContextOptions 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.
+ // Zwei Zustände, nicht einer. 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.
+ //
+ // Sichtbar heisst nicht abspielbar. 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().HasQueryFilter(m =>
- AuchUnveroeffentlichtes || (m.PublishAt != null && m.PublishAt <= Jetzt));
+ AuchUnveroeffentlichtes || m.PublishAt != null);
b.Entity().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().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(e =>
{
diff --git a/server/src/Flaro.Data/MetadataEnricher.cs b/server/src/Flaro.Data/MetadataEnricher.cs
index 8f12184..e0bbb6f 100644
--- a/server/src/Flaro.Data/MetadataEnricher.cs
+++ b/server/src/Flaro.Data/MetadataEnricher.cs
@@ -31,6 +31,7 @@ public sealed class MetadataEnricher(
IImageReader bildLeser,
ISidecarStore beiwerk,
Zusammenlegung zusammenlegung,
+ TimeProvider uhr,
ILogger log)
{
///
@@ -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 FilmeZuordnen(
Guid libraryId, bool erneut, IProgress? fortschritt, CancellationToken ct,
- string? nurSprache = null, IReadOnlySet? nurDiese = null)
+ string? nurSprache = null, IReadOnlySet? 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 SerienZuordnen(
Guid libraryId, bool erneut, IProgress? fortschritt, CancellationToken ct,
- string? nurSprache = null, IReadOnlySet? nurDiese = null)
+ string? nurSprache = null, IReadOnlySet? 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++;
diff --git a/server/src/Flaro.Data/Migrations/20261006123929_Kenntnisnahme.Designer.cs b/server/src/Flaro.Data/Migrations/20261006123929_Kenntnisnahme.Designer.cs
new file mode 100644
index 0000000..8fa6002
--- /dev/null
+++ b/server/src/Flaro.Data/Migrations/20261006123929_Kenntnisnahme.Designer.cs
@@ -0,0 +1,933 @@
+//
+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
+ {
+ ///
+ 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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("ApprovedByUserId")
+ .HasColumnType("TEXT");
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("TEXT");
+
+ b.Property("ConsumedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("CreatedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("DeviceId")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("TEXT");
+
+ b.Property("DeviceName")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("TEXT");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("DeviceId")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("TEXT");
+
+ b.Property("DeviceName")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("TEXT");
+
+ b.Property("LastSeenAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("RevokedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("TokenFingerprint")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property("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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("Kind")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("TEXT");
+
+ b.Property("Path")
+ .IsRequired()
+ .HasMaxLength(1024)
+ .HasColumnType("TEXT");
+
+ b.Property("ReviewNewItems")
+ .HasColumnType("INTEGER");
+
+ b.Property("SortOrder")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.ToTable("Libraries");
+ });
+
+ modelBuilder.Entity("Flaro.Core.Library.Credit", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("Character")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("MovieId")
+ .HasColumnType("TEXT");
+
+ b.Property("Order")
+ .HasColumnType("INTEGER");
+
+ b.Property("PersonId")
+ .HasColumnType("TEXT");
+
+ b.Property("Role")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("TEXT");
+
+ b.Property("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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("AddedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("EpisodeNumber")
+ .HasColumnType("INTEGER");
+
+ b.Property("MetadataLanguage")
+ .HasMaxLength(16)
+ .HasColumnType("TEXT");
+
+ b.Property("Overview")
+ .HasColumnType("TEXT");
+
+ b.Property("PublishAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("SeasonNumber")
+ .HasColumnType("INTEGER");
+
+ b.Property("SeriesId")
+ .HasColumnType("TEXT");
+
+ b.Property("StillImage")
+ .HasColumnType("TEXT");
+
+ b.Property("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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("GenreId")
+ .HasColumnType("TEXT");
+
+ b.Property("MovieId")
+ .HasColumnType("TEXT");
+
+ b.Property("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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("EpisodeId")
+ .HasColumnType("TEXT");
+
+ b.Property("FetchedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("Language")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("TEXT");
+
+ b.Property("MovieId")
+ .HasColumnType("TEXT");
+
+ b.Property("Overview")
+ .HasColumnType("TEXT");
+
+ b.Property("SeriesId")
+ .HasColumnType("TEXT");
+
+ b.Property("Tagline")
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property("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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("BitRate")
+ .HasColumnType("INTEGER");
+
+ b.Property("Container")
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property("Duration")
+ .HasColumnType("INTEGER");
+
+ b.Property("EpisodeId")
+ .HasColumnType("TEXT");
+
+ b.Property("LibraryId")
+ .HasColumnType("TEXT");
+
+ b.Property("MissingSince")
+ .HasColumnType("INTEGER");
+
+ b.Property("ModifiedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("MovieId")
+ .HasColumnType("TEXT");
+
+ b.Property("Path")
+ .IsRequired()
+ .HasMaxLength(1024)
+ .HasColumnType("TEXT");
+
+ b.Property("ProbedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("SeenAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("SizeBytes")
+ .HasColumnType("INTEGER");
+
+ b.Property("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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("Channels")
+ .HasColumnType("INTEGER");
+
+ b.Property("Codec")
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property("Height")
+ .HasColumnType("INTEGER");
+
+ b.Property("Index")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsDefault")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsForced")
+ .HasColumnType("INTEGER");
+
+ b.Property("Kind")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("TEXT");
+
+ b.Property("Language")
+ .HasMaxLength(16)
+ .HasColumnType("TEXT");
+
+ b.Property("MediaFileId")
+ .HasColumnType("TEXT");
+
+ b.Property("Title")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("Width")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("MediaFileId", "Index")
+ .IsUnique();
+
+ b.ToTable("MediaStreams");
+ });
+
+ modelBuilder.Entity("Flaro.Core.Library.Movie", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("AddedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("BackdropImage")
+ .HasColumnType("TEXT");
+
+ b.Property("BackdropPinned")
+ .HasColumnType("INTEGER");
+
+ b.Property("ChangedSince")
+ .HasColumnType("INTEGER");
+
+ b.Property("ContentRating")
+ .HasMaxLength(16)
+ .HasColumnType("TEXT");
+
+ b.Property("ImdbId")
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property("LibraryId")
+ .HasColumnType("TEXT");
+
+ b.Property("LogoImage")
+ .HasColumnType("TEXT");
+
+ b.Property("LogoPinned")
+ .HasColumnType("INTEGER");
+
+ b.Property("MatchLocked")
+ .HasColumnType("INTEGER");
+
+ b.Property("MetadataLanguage")
+ .HasMaxLength(16)
+ .HasColumnType("TEXT");
+
+ b.Property("OriginalTitle")
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property("Overview")
+ .HasColumnType("TEXT");
+
+ b.Property("ParsedTitle")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("ParsedYear")
+ .HasColumnType("INTEGER");
+
+ b.Property("PosterImage")
+ .HasColumnType("TEXT");
+
+ b.Property("PosterPinned")
+ .HasColumnType("INTEGER");
+
+ b.Property("PublishAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("Rating")
+ .HasColumnType("REAL");
+
+ b.Property("RuntimeMinutes")
+ .HasColumnType("INTEGER");
+
+ b.Property("SortTitlePinned")
+ .HasColumnType("INTEGER");
+
+ b.Property("SortTitleValue")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property("Tagline")
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property("TitlePinned")
+ .HasColumnType("INTEGER");
+
+ b.Property("TmdbId")
+ .HasColumnType("INTEGER");
+
+ b.Property("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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("Image")
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("TmdbId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TmdbId")
+ .IsUnique();
+
+ b.ToTable("People");
+ });
+
+ modelBuilder.Entity("Flaro.Core.Library.Series", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("AddedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("BackdropImage")
+ .HasColumnType("TEXT");
+
+ b.Property("BackdropPinned")
+ .HasColumnType("INTEGER");
+
+ b.Property("ChangedSince")
+ .HasColumnType("INTEGER");
+
+ b.Property("ContentRating")
+ .HasMaxLength(16)
+ .HasColumnType("TEXT");
+
+ b.Property("ImdbId")
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property("LibraryId")
+ .HasColumnType("TEXT");
+
+ b.Property("LogoImage")
+ .HasColumnType("TEXT");
+
+ b.Property("LogoPinned")
+ .HasColumnType("INTEGER");
+
+ b.Property("MatchLocked")
+ .HasColumnType("INTEGER");
+
+ b.Property("MetadataLanguage")
+ .HasMaxLength(16)
+ .HasColumnType("TEXT");
+
+ b.Property("OriginalTitle")
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property("Overview")
+ .HasColumnType("TEXT");
+
+ b.Property("ParsedTitle")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("ParsedYear")
+ .HasColumnType("INTEGER");
+
+ b.Property("PosterImage")
+ .HasColumnType("TEXT");
+
+ b.Property("PosterPinned")
+ .HasColumnType("INTEGER");
+
+ b.Property("PublishAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("Rating")
+ .HasColumnType("REAL");
+
+ b.Property("RuntimeMinutes")
+ .HasColumnType("INTEGER");
+
+ b.Property("SortTitlePinned")
+ .HasColumnType("INTEGER");
+
+ b.Property("SortTitleValue")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property("Tagline")
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property("TitlePinned")
+ .HasColumnType("INTEGER");
+
+ b.Property("TmdbId")
+ .HasColumnType("INTEGER");
+
+ b.Property("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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("Dismissed")
+ .HasColumnType("INTEGER");
+
+ b.Property("EpisodeId")
+ .HasColumnType("TEXT");
+
+ b.Property("Favourite")
+ .HasColumnType("INTEGER");
+
+ b.Property("InProgress")
+ .HasColumnType("INTEGER");
+
+ b.Property("LastPlayedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("MovieId")
+ .HasColumnType("TEXT");
+
+ b.Property("PlayCount")
+ .HasColumnType("INTEGER");
+
+ b.Property("Position")
+ .HasColumnType("INTEGER");
+
+ b.Property("Rating")
+ .HasColumnType("INTEGER");
+
+ b.Property("Runtime")
+ .HasColumnType("INTEGER");
+
+ b.Property("SeriesId")
+ .HasColumnType("TEXT");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property("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("Key")
+ .HasMaxLength(128)
+ .HasColumnType("TEXT");
+
+ b.Property("Value")
+ .IsRequired()
+ .HasMaxLength(4096)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Key");
+
+ b.ToTable("Settings");
+ });
+
+ modelBuilder.Entity("Flaro.Core.Users.User", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsAdministrator")
+ .HasColumnType("INTEGER");
+
+ b.Property("Language")
+ .HasMaxLength(16)
+ .HasColumnType("TEXT");
+
+ b.Property("PasswordHash")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("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
+ }
+ }
+}
diff --git a/server/src/Flaro.Data/Migrations/20261006123929_Kenntnisnahme.cs b/server/src/Flaro.Data/Migrations/20261006123929_Kenntnisnahme.cs
new file mode 100644
index 0000000..9f50d11
--- /dev/null
+++ b/server/src/Flaro.Data/Migrations/20261006123929_Kenntnisnahme.cs
@@ -0,0 +1,38 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Flaro.Data.Migrations
+{
+ ///
+ public partial class Kenntnisnahme : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn(
+ name: "ChangedSince",
+ table: "Series",
+ type: "INTEGER",
+ nullable: true);
+
+ migrationBuilder.AddColumn(
+ name: "ChangedSince",
+ table: "Movies",
+ type: "INTEGER",
+ nullable: true);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "ChangedSince",
+ table: "Series");
+
+ migrationBuilder.DropColumn(
+ name: "ChangedSince",
+ table: "Movies");
+ }
+ }
+}
diff --git a/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs b/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs
index 5827d17..6ceb245 100644
--- a/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs
+++ b/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs
@@ -455,6 +455,9 @@ namespace Flaro.Data.Migrations
b.Property("BackdropPinned")
.HasColumnType("INTEGER");
+ b.Property("ChangedSince")
+ .HasColumnType("INTEGER");
+
b.Property("ContentRating")
.HasMaxLength(16)
.HasColumnType("TEXT");
@@ -587,6 +590,9 @@ namespace Flaro.Data.Migrations
b.Property("BackdropPinned")
.HasColumnType("INTEGER");
+ b.Property("ChangedSince")
+ .HasColumnType("INTEGER");
+
b.Property("ContentRating")
.HasMaxLength(16)
.HasColumnType("TEXT");
diff --git a/server/tests/Flaro.Api.Tests/FreigabeTests.cs b/server/tests/Flaro.Api.Tests/FreigabeTests.cs
index ee0c87a..e693129 100644
--- a/server/tests/Flaro.Api.Tests/FreigabeTests.cs
+++ b/server/tests/Flaro.Api.Tests/FreigabeTests.cs
@@ -112,9 +112,24 @@ public class FreigabeTests
Assert.That(await Titel(gast), Is.EqualTo(new[] { "Geheimsache" }));
}
- /// Ein Zeitpunkt in der Zukunft hält ihn zurück — ohne Hintergrunddienst.
+ /// Ein geplanter Titel ist sichtbar, aber noch nicht abspielbar.
+ ///
+ ///
+ ///
+ /// Zwei Zustände, nicht einer. 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.
+ ///
+ ///
+ /// Sichtbar heisst aber nicht abspielbar, 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.
+ ///
+ ///
[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>("/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>("/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");
}
/// Ohne Prüfung erscheint alles sofort — das ist die Vorgabe.
diff --git a/web/app/src/api.ts b/web/app/src/api.ts
index 465d3d7..3636feb 100644
--- a/web/app/src/api.ts
+++ b/web/app/src/api.ts
@@ -15,12 +15,16 @@ export type Spur = {
channels: number | null; width: number | null; height: number | null;
/** Die fertige Zeile vom Server, etwa Deutsch · Dolby Digital Plus · 5.1. */
display: string | null;
+ /** Die Sprache als Wort — „Deutsch" statt „deu". */
+ languageName: string | null;
};
export type Datei = {
id: string; fileName: string; sizeBytes: number;
container: string | null; durationSeconds: number | null; streams: Spur[];
/** „Mobil", „4K", „Director's Cut" — oder `null`, wenn es nur eine gibt. */
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. */
export type Stand = {
@@ -698,6 +702,8 @@ export const bildHochladen = async (
};
export type Pruefeintrag = {
+ /** „neu" (wartet auf Freigabe) oder „geaendert" (nur zur Kenntnis). */
+ grund?: 'neu' | 'geaendert';
kind: 'movie' | 'series';
id: string; title: string; year: number | null;
posterImage: string | null;
@@ -713,6 +719,14 @@ export type Pruefeintrag = {
export const zuPruefen = () => anfrage('/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. */
export const veroeffentlichen = (ids?: string[], publishAt?: string) =>
anfrage<{ published: number }>('/review/publish',
diff --git a/web/app/src/icons.ts b/web/app/src/icons.ts
index 6d73d09..7626462 100644
--- a/web/app/src/icons.ts
+++ b/web/app/src/icons.ts
@@ -71,6 +71,9 @@ export const icons = {
regler: rahmen(''
+ ''),
+ /** Eine Uhr — für alles, was erst später gilt. */
+ uhr: rahmen(''),
+
/** Den Spieler auf die Leiste unten schrumpfen. */
verkleinern: rahmen(''),
diff --git a/web/app/src/style.css b/web/app/src/style.css
index 707ed07..d4ee333 100644
--- a/web/app/src/style.css
+++ b/web/app/src/style.css
@@ -1626,3 +1626,29 @@ body:has(.auswahlleiste:not(.hidden)) .hinweise { bottom: 104px; }
100% { background: transparent; }
}
@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; }
diff --git a/web/app/src/views/admin.ts b/web/app/src/views/admin.ts
index cbe4578..11a99a3 100644
--- a/web/app/src/views/admin.ts
+++ b/web/app/src/views/admin.ts
@@ -3,7 +3,7 @@ import {
scanStarten, scanstand, metadatenAuffrischen, doppelteZusammenlegen,
vermisste, vermisstesEntfernen, type VermissteDatei,
protokolltage, protokollAdresse, sprachen, spracheSetzen, uebersetzungenHolen,
- meineSprache, zuPruefen, veroeffentlichen, zurueckziehen, pruefungSetzen,
+ meineSprache, zuPruefen, veroeffentlichen, zurueckziehen, pruefungSetzen, zurKenntnis,
mediathekZuruecksetzen,
type Pruefeintrag,
benutzer, benutzerAnlegen, metadatenEinstellung, metadatenSetzen,
@@ -290,17 +290,48 @@ function pruefbereich(geaendert: () => void): {
freigeben.textContent = `Veröffentlichen (${wieViele})`;
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 = 'Geändert'
+ + 'Diese Titel sind schon sichtbar. Ein Auffrischen '
+ + 'hat Titel, Jahr oder Plakat geändert — hier nur zur Kenntnis.';
+
+ 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 {
const d = document.createElement('div');
d.className = 'einstellung pruefzeile';
+ const geaendertes = e.grund === 'geaendert';
+
const haken = document.createElement('input');
haken.type = 'checkbox';
haken.checked = gewaehlt.has(e.id);
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 = () => {
if (haken.checked) gewaehlt.add(e.id); else gewaehlt.delete(e.id);
zeichnen();
@@ -323,11 +354,13 @@ function pruefbereich(geaendert: () => void): {
const was = e.kind === 'series'
? `${e.count} ${e.count === 1 ? 'Folge' : 'Folgen'}`
: null;
- const lage = geplant
- ? `Geplant für ${geplant.toLocaleString('de-CH', { dateStyle: 'medium', timeStyle: 'short' })}`
- : e.kind === 'series' && e.count > 1 && !e.publishAt
- ? 'Wartet'
- : 'Wartet auf Freigabe';
+ const lage = geaendertes
+ ? 'Sichtbar — ein Auffrischen hat etwas geändert'
+ : geplant
+ ? `Geplant für ${geplant.toLocaleString('de-CH', { dateStyle: 'medium', timeStyle: 'short' })}`
+ : e.kind === 'series' && e.count > 1 && !e.publishAt
+ ? 'Wartet'
+ : 'Wartet auf Freigabe';
// Ohne Zuordnung fehlen Titel und Bilder — dann ist nicht die Freigabe
// das Nächste, sondern die Zuordnung.
const hinweis = e.matched ? null : 'noch keinem Titel zugeordnet';
@@ -335,11 +368,19 @@ function pruefbereich(geaendert: () => void): {
text.innerHTML = `${esc(e.title)}${e.year ? ` (${e.year})` : ''}`
+ `${[was, lage, hinweis].filter(Boolean).map(esc).join(' · ')}`;
- 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.disabled = true;
- try { await veroeffentlichen(e.ids); await laden(); geaendert(); }
- catch (fehler) { d.appendChild(fehlerzeile(meldung(fehler))); nur.disabled = false; }
+ try {
+ 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.**
diff --git a/web/app/src/views/detail.ts b/web/app/src/views/detail.ts
index 626bf23..e6dbd8c 100644
--- a/web/app/src/views/detail.ts
+++ b/web/app/src/views/detail.ts
@@ -239,7 +239,25 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
const hinweisfeld = document.createElement('div');
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 los = knopf(stelle > 0 ? `Fortsetzen ab ${dauer(stelle)}` : 'Abspielen', 'btn btn-primary');
los.prepend(icon('abspielen'));
@@ -305,6 +323,19 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
/* ---------- Medienangaben ---------- */
/** 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 {
const liste = document.createElement('dl');
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 text = datei.streams.filter(s => s.kind === 'Subtitle');
- zeile('Datei', [datei.container?.toUpperCase(), groesse(datei.sizeBytes),
- dauer(datei.durationSeconds)].filter(Boolean).join(' · '));
- // **Auch hier die Formulierung vom Server.** Vorher baute diese Zeile
- // ihren Text selbst — „240p (H264)" statt „240p · H.264" —, und damit
- // stand in derselben Tabelle zweierlei Schreibweise untereinander.
+ /* ---------- Für alle ---------- */
+
+ if (datei.durationSeconds) zeile('Laufzeit', laufzeit(datei.durationSeconds));
+
if (bild) {
- const v = zeile('Video', spurname(bild));
- // Die gemessene Höhe gehört in den Tooltip: 1040 ist keine Stufe,
- // sondern ein beschnittenes Bild.
- if (bild.width && bild.height) v.title = `${bild.width} × ${bild.height}`;
+ // Nur die Stufe, nicht der Codec: „H.264" ist für den, der schauen
+ // will, kein Unterschied, den er trifft.
+ 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}`;
+ }
+ }
+
+ // **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.`;
}
- if (ton.length) zeile('Ton', ton.map(spurname).join(' · '));
- if (text.length) zeile('Untertitel', text.map(spurname).join(' · '));
function zeile(name: string, wert: string): HTMLElement {
const dt = document.createElement('dt');
@@ -339,17 +410,6 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
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 ---------- */