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 ---------- */