diff --git a/server/Dockerfile b/server/Dockerfile index a94b1de..a4b32be 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -48,7 +48,7 @@ FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime ARG TARGETARCH RUN apt-get update \ && apt-get install -y --no-install-recommends \ - ffmpeg libva2 libva-drm2 mesa-va-drivers \ + ffmpeg libva2 libva-drm2 mesa-va-drivers curl \ && if [ "$TARGETARCH" = "amd64" ]; then \ apt-get install -y --no-install-recommends intel-media-va-driver i965-va-driver; \ fi \ @@ -67,10 +67,13 @@ RUN mkdir -p /config /cache /transcode && chown -R 1000:1000 /config /cache /tra # braucht weder ein Startskript noch root im Container. USER 1000:1000 +# **Kein eigenes ASPNETCORE_URLS.** Das Basisimage setzt seit .NET 8 selbst +# ASPNETCORE_HTTP_PORTS=8080; beides zusammen ergab bei jedem Start die +# Warnung „Overriding HTTP_PORTS '8080'". Zwei Wege zum selben Port sind +# einer zu viel. ENV FLARO_DATA=/config \ FLARO_CACHE=/cache \ FLARO_TRANSCODE=/transcode \ - ASPNETCORE_URLS=http://+:8080 \ DOTNET_gcServer=0 WORKDIR /app @@ -78,6 +81,20 @@ COPY --from=build /app . COPY --from=web /web/dist ./wwwroot EXPOSE 8080 + +# **Eine Gesundheitsprüfung, die den Server fragt — nicht die Laufzeit.** +# +# Ohne sie weiss Docker nur, dass ein Prozess läuft. Die Oberfläche eines NAS +# zeigt dann minutenlang „wird bereitgestellt", während der Server längst +# antwortet, und niemand kann unterscheiden, ob er noch wandert oder hängt. +# +# `/health` und nicht `dotnet --info`: letzteres sagt nur, dass die Laufzeit +# da ist, und das war sie auch, als der Server noch migrierte. +# +# 60 Sekunden Anlaufzeit, weil die Migrationen beim ersten Start einer +# gefüllten Datenbank länger brauchen als ein Startvorgang sonst. +HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \ + CMD curl -fsS http://localhost:8080/health || exit 1 VOLUME ["/config", "/cache", "/transcode"] ENTRYPOINT ["dotnet", "Flaro.Api.dll"] diff --git a/server/docker-compose.example.yml b/server/docker-compose.example.yml index c68e1b3..e59092f 100644 --- a/server/docker-compose.example.yml +++ b/server/docker-compose.example.yml @@ -67,9 +67,7 @@ services: devices: - /dev/dri:/dev/dri - healthcheck: - test: ["CMD", "dotnet", "--info"] - interval: 30s - timeout: 5s - retries: 3 - start_period: 20s + # Die Gesundheitsprüfung steckt im Image und fragt /health ab — hier + # braucht es sie nicht noch einmal. Die alte Fassung prüfte `dotnet + # --info` und sagte damit nur, dass die Laufzeit da ist; das war sie auch, + # während der Server noch migrierte. diff --git a/server/src/Flaro.Api/Endpoints/Contracts.cs b/server/src/Flaro.Api/Endpoints/Contracts.cs index 218fa10..ed3dbb9 100644 --- a/server/src/Flaro.Api/Endpoints/Contracts.cs +++ b/server/src/Flaro.Api/Endpoints/Contracts.cs @@ -85,13 +85,32 @@ public sealed record Bildauswahl( public sealed record BildWahlRequest(string Kind, string? Url); /// Ein Titel, der noch wartet oder geplant ist. +/// +/// +/// Eine Zeile je Serie, nicht je Folge. Bei zwanzig neuen Folgen +/// derselben Serie wären zwanzig Zeilen zwanzig Entscheidungen für eine +/// Sache. Die Folgen stecken in ; freigegeben werden sie +/// gemeinsam. +/// public sealed record Pruefeintrag( - string Kind, Guid Id, string Title, int? Year, string? PosterImage, + /// movie oder series. + string Kind, + Guid Id, string Title, int? Year, string? PosterImage, /// null heisst: wartet. Ein Zeitpunkt in der Zukunft heisst: geplant. + /// + /// + /// Bei einer Serie nur gesetzt, wenn alle Folgen denselben Termin tragen — + /// sonst wäre es eine Behauptung über zwanzig Folgen, von denen eine + /// anders liegt. + /// DateTimeOffset? PublishAt, string? Path, /// Ob eine Zuordnung besteht — ohne sie fehlen Titel und Bilder. - bool Matched); + bool Matched, + /// Wie viele Titel dahinterstecken — bei einer Serie die Zahl der Folgen. + int Count, + /// Worauf eine Freigabe wirkt. + IReadOnlyList Ids); public sealed record FreigabeRequest(IReadOnlyList? Ids, DateTimeOffset? PublishAt); diff --git a/server/src/Flaro.Api/Endpoints/FreigabeEndpoints.cs b/server/src/Flaro.Api/Endpoints/FreigabeEndpoints.cs index 4c6506f..faa39d7 100644 --- a/server/src/Flaro.Api/Endpoints/FreigabeEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/FreigabeEndpoints.cs @@ -26,24 +26,56 @@ public static class FreigabeEndpoints .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)) + m.TmdbId != null, 1, new List { m.Id })) .ToListAsync(ct); - var folgen = await db.Episodes.AsNoTracking() + // **Folgen werden nach Serie gebündelt.** Zwanzig neue Folgen + // derselben Serie sind eine Entscheidung, nicht zwanzig. + var offeneFolgen = await db.Episodes.AsNoTracking() .Where(f => f.PublishAt == null || f.PublishAt > jetzt) - .OrderBy(f => f.SeasonNumber).ThenBy(f => f.EpisodeNumber) - .Select(f => new Pruefeintrag( - "episode", f.Id, - db.Series.Where(s => s.Id == f.SeriesId).Select(s => s.Title).First() - + $" · S{f.SeasonNumber}E{f.EpisodeNumber}", - null, - db.Series.Where(s => s.Id == f.SeriesId).Select(s => s.PosterImage).First(), - f.PublishAt, - db.MediaFiles.Where(d => d.EpisodeId == f.Id).Select(d => d.Path).FirstOrDefault(), - true)) + .Select(f => new + { + f.Id, f.SeriesId, f.SeasonNumber, f.EpisodeNumber, f.PublishAt, + Pfad = db.MediaFiles.Where(d => d.EpisodeId == f.Id) + .Select(d => d.Path).FirstOrDefault(), + }) .ToListAsync(ct); - return Results.Ok(filme.Concat(folgen).ToList()); + var serienIds = offeneFolgen.Select(f => f.SeriesId).Distinct().ToList(); + var serien = await db.Series.AsNoTracking() + .Where(s => serienIds.Contains(s.Id)) + .Select(s => new { s.Id, s.Title, s.Year, s.PosterImage, Zugeordnet = s.TmdbId != null }) + .ToDictionaryAsync(s => s.Id, ct); + + var gebuendelt = offeneFolgen + .GroupBy(f => f.SeriesId) + .Select(g => + { + var serie = serien.GetValueOrDefault(g.Key); + var termine = g.Select(f => f.PublishAt).Distinct().ToList(); + + return new Pruefeintrag( + "series", g.Key, + serie?.Title ?? "Unbekannte Serie", + serie?.Year, + serie?.PosterImage, + // Nur wenn alle denselben Termin tragen — sonst wäre es + // eine Behauptung über Folgen, die anders liegen. + termine.Count == 1 ? termine[0] : null, + Pfad(g.Select(f => f.Pfad).FirstOrDefault()), + serie?.Zugeordnet ?? false, + g.Count(), + g.OrderBy(f => f.SeasonNumber).ThenBy(f => f.EpisodeNumber) + .Select(f => f.Id).ToList()); + }) + .OrderBy(e => e.Title) + .ToList(); + + return Results.Ok(filme.Concat(gebuendelt).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); }); // Veröffentlichen oder planen. Ohne Zeitpunkt heisst: jetzt. diff --git a/server/src/Flaro.Api/Program.cs b/server/src/Flaro.Api/Program.cs index 94b191b..ae13e0c 100644 --- a/server/src/Flaro.Api/Program.cs +++ b/server/src/Flaro.Api/Program.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.DataProtection; using System.Threading.RateLimiting; using Flaro.Api; using Flaro.Api.Auth; @@ -99,6 +100,22 @@ builder.Services.AddSingleton(); // ueber den Augenblick. Nach einem Neustart lautet die Antwort: niemand. builder.Services.AddSingleton(); builder.Services.AddSingleton(); + +// **Die Schlüssel der Datenverschlüsselung gehören ins Volume.** +// +// Ohne diese Zeile legt ASP.NET sie unter `~/.aspnet/DataProtection-Keys` ab — +// also im Container, der beim nächsten Update weg ist. Heute fällt das nicht +// auf, weil Flaros Sitzungs-Cookie das Gerätetoken selbst trägt und nichts +// davon verschlüsselt wird. Es fällt erst auf, wenn irgendwann etwas doch +// darauf baut, und dann sieht es aus wie ein Fehler anderswo. +// +// Nebenbei verschwindet damit die Warnung, die bei jedem Start im Protokoll +// stand und nach einem Problem aussah, das keines war. +var schluesselOrdner = Path.Combine(pfade.Data, "keys"); +Directory.CreateDirectory(schluesselOrdner); +builder.Services.AddDataProtection() + .PersistKeysToFileSystem(new DirectoryInfo(schluesselOrdner)) + .SetApplicationName("Flaro"); builder.Services.AddProblemDetails(); var app = builder.Build(); diff --git a/server/src/Flaro.Core/Library/FileNames.cs b/server/src/Flaro.Core/Library/FileNames.cs index db868d7..929e60e 100644 --- a/server/src/Flaro.Core/Library/FileNames.cs +++ b/server/src/Flaro.Core/Library/FileNames.cs @@ -206,13 +206,61 @@ public static partial class FileNames var (worteDanach, _) = Zerlegen(danach, istDateiname: false); var (folgenTitel, _) = TitelBis(worteDanach, worteDanach.Length); + // **Steht im Dateinamen keine Serie, sagt der Ordner sie.** + // + // Bei „Dark/Staffel 1/S01E01.mkv" steht vor der Folgennummer nichts — + // und bis hierher wurde dann der ganze Dateiname zum Serientitel. + // Jede so benannte Datei ergab damit eine eigene Serie: aus zwanzig + // Folgen wurden zwanzig Serien namens „S01E01", „S01E02", … + // + // Plex und Jellyfin lesen den Namen seit je aus dem Ordner. Der + // Staffelordner wird dabei übersprungen, sonst hiesse die Serie + // „Staffel 1". + var ausOrdner = serie.Length > 0 ? null : SerieAusOrdner(dateiname); + return new EpisodeName( - SeriesTitle: serie.Length > 0 ? serie : Rohname(dateiname), + SeriesTitle: serie.Length > 0 ? serie : ausOrdner ?? Rohname(dateiname), Season: staffel, Episode: folge, EpisodeTitle: folgenTitel.Length > 0 ? folgenTitel : null); } + /// Der Serienname aus dem Pfad — Staffelordner übersprungen. + /// + /// + /// Höchstens zwei Ebenen hoch. Weiter oben steht der Wurzelordner + /// der Mediathek, und „Serien" ist kein Serientitel. + /// + private static string? SerieAusOrdner(string pfad) + { + var ordner = Path.GetDirectoryName(pfad); + + for (var ebene = 0; ebene < 2 && !string.IsNullOrEmpty(ordner); ebene++) + { + var name = Path.GetFileName(ordner); + if (name.Length == 0) return null; + + if (!IstStaffelordner(name)) + { + var (worte, _) = Zerlegen(name, istDateiname: false); + var (titel, _) = TitelBis(worte, worte.Length); + return titel.Length > 0 ? titel : null; + } + + ordner = Path.GetDirectoryName(ordner); + } + + return null; + } + + /// „Staffel 1", „Season 02", „S3" — Ordner, die keine Serie benennen. + private static bool IstStaffelordner(string name) => + StaffelOrdnerName().IsMatch(name.Trim()); + + [GeneratedRegex(@"^(staffel|season|series|s)[\s._-]*\d{1,3}$|^(specials?|extras?)$", + RegexOptions.IgnoreCase)] + private static partial Regex StaffelOrdnerName(); + /// Nimmt Worte, bis das erste Beiwerk oder eine Jahreszahl kommt. private static (string Titel, int? Jahr) TitelBis(string[] worte, int grenze) { diff --git a/server/src/Flaro.Data/MetadataEnricher.cs b/server/src/Flaro.Data/MetadataEnricher.cs index b1aea03..936678e 100644 --- a/server/src/Flaro.Data/MetadataEnricher.cs +++ b/server/src/Flaro.Data/MetadataEnricher.cs @@ -30,6 +30,7 @@ public sealed class MetadataEnricher( IImageStore bilder, IImageReader bildLeser, ISidecarStore beiwerk, + Zusammenlegung zusammenlegung, ILogger log) { /// @@ -81,6 +82,14 @@ public sealed class MetadataEnricher( var zugeordnet = 0; var durch = 0; + // Wer welche Kennung schon hat — auch die, die dieser Lauf gerade + // vergeben hat und die deshalb noch nicht in der Datenbank stehen. + var vergeben = await db.Movies + .Where(m => m.LibraryId == libraryId && m.TmdbId != null) + .ToDictionaryAsync(m => m.TmdbId!.Value, m => m.Id, ct); + + var zusammenzulegen = new List<(Guid Sieger, Guid Verlierer)>(); + foreach (var film in offen) { ct.ThrowIfCancellationRequested(); @@ -101,6 +110,24 @@ public sealed class MetadataEnricher( if (gefunden is null) continue; + // **Zwei Einträge, derselbe Film — und das fällt erst hier auf.** + // + // Beim Scan heisst der eine „Ich einfach unverbesserlich" und der + // andere „Despicable Me"; dass es derselbe ist, verrät allein die + // TMDB-Kennung. Die hier blind zu setzen verletzt den + // Eindeutigkeitsindex, und EF bricht die ganze Speicherung ab — + // mit „An error occurred while saving the entity changes", einer + // Meldung, die den Grund nicht nennt und den ganzen Lauf mitnimmt. + // + // Also nicht setzen, sondern vormerken: zusammengelegt wird nach + // der Schleife, wenn nichts mehr dazwischenkommt. + if (vergeben.TryGetValue(gefunden.ProviderId, out var schonDa)) + { + zusammenzulegen.Add((Sieger: schonDa, Verlierer: film.Id)); + continue; + } + + vergeben[gefunden.ProviderId] = film.Id; film.TmdbId = gefunden.ProviderId; film.ImdbId = gefunden.ImdbId; film.Title = gefunden.Title; @@ -128,6 +155,23 @@ public sealed class MetadataEnricher( } if (zugeordnet > 0) await db.SaveChangesAsync(ct); + + // **Erst speichern, dann zusammenlegen.** Das Verschmelzen löscht + // Zeilen am Änderungsverfolger vorbei; stünde es davor, schriebe das + // folgende Speichern sie wieder hin. + foreach (var (sieger, verlierer) in zusammenzulegen) + { + await zusammenlegung.FilmVerschmelzenAsync(sieger, verlierer, ct); + } + + if (zusammenzulegen.Count > 0) + { + await db.SaveChangesAsync(ct); + log.LogInformation( + "{Anzahl} Film(e) waren derselbe Titel unter anderem Namen und wurden zusammengelegt.", + zusammenzulegen.Count); + } + return zugeordnet; } @@ -160,6 +204,12 @@ public sealed class MetadataEnricher( var zugeordnet = 0; var durch = 0; + var vergeben = await db.Series + .Where(x => x.LibraryId == libraryId && x.TmdbId != null) + .ToDictionaryAsync(x => x.TmdbId!.Value, x => x.Id, ct); + + var zusammenzulegen = new List<(Guid Sieger, Guid Verlierer)>(); + foreach (var serie in offen) { ct.ThrowIfCancellationRequested(); @@ -181,6 +231,17 @@ public sealed class MetadataEnricher( if (gefunden is null) continue; + // Wie beim Film: zwei Einträge, dieselbe Serie. Bei Serien ist das + // noch häufiger — derselbe Ordner unter zwei Schreibweisen, oder + // ein Staffelordner, der versehentlich als eigene Serie gelesen + // wurde. + if (vergeben.TryGetValue(gefunden.ProviderId, out var schonDa)) + { + zusammenzulegen.Add((Sieger: schonDa, Verlierer: serie.Id)); + continue; + } + + vergeben[gefunden.ProviderId] = serie.Id; serie.TmdbId = gefunden.ProviderId; serie.ImdbId = gefunden.ImdbId; serie.Title = gefunden.Title; @@ -208,6 +269,20 @@ public sealed class MetadataEnricher( } if (zugeordnet > 0) await db.SaveChangesAsync(ct); + + foreach (var (sieger, verlierer) in zusammenzulegen) + { + await zusammenlegung.SerieVerschmelzenAsync(sieger, verlierer, ct); + } + + if (zusammenzulegen.Count > 0) + { + await db.SaveChangesAsync(ct); + log.LogInformation( + "{Anzahl} Serie(n) waren dieselbe unter anderem Namen und wurden zusammengelegt.", + zusammenzulegen.Count); + } + return zugeordnet; } diff --git a/server/tests/Flaro.Api.Tests/MetadataTests.cs b/server/tests/Flaro.Api.Tests/MetadataTests.cs index 4ed5cf3..29a897b 100644 --- a/server/tests/Flaro.Api.Tests/MetadataTests.cs +++ b/server/tests/Flaro.Api.Tests/MetadataTests.cs @@ -155,6 +155,73 @@ public class MetadataTests Assert.That(bilder.Geholt, Is.EquivalentTo(new[] { "https://bild/p.jpg", "https://bild/b.jpg" })); } + /// Zwei Namen, ein Film — das darf den Lauf nicht abbrechen. + /// + /// + /// + /// Der Fehler, den der Eindeutigkeitsindex selbst verursacht hat. + /// Beim Scan heisst der eine Eintrag „Ich einfach unverbesserlich" und + /// der andere „Despicable Me"; dass es derselbe Film ist, verrät erst die + /// TMDB-Kennung. Wurde sie beiden zugewiesen, verletzte das die Regel — + /// und EF brach die gesamte Speicherung ab, mit „An error occurred while + /// saving the entity changes". Die Meldung nennt den Grund nicht und + /// nimmt den ganzen Lauf mit. + /// + /// + /// Hier antwortet der Anbieter auf jede Anfrage mit demselben Film — also + /// genau die Lage, die vorher den Lauf zerriss. + /// + /// + [Test] + public async Task Zwei_Namen_fuer_denselben_Film_werden_zusammengelegt() + { + Datei("Despicable Me (2010).mkv"); + Datei("Ich einfach unverbesserlich (2010).mkv"); + + var anbieter = new VorgegebenerAnbieter + { + Film = new MovieMetadata(20352, "Ich einfach unverbesserlich", 2010, "Gru hat einen Plan.", + null, null), + }; + + using var server = MitAnbieter(anbieter); + var (client, bibliothek) = await Einrichten(server, "Movies"); + var stand = await Scannen(client, bibliothek); + + Assert.That(stand.State, Is.EqualTo("finished"), "Der Lauf darf nicht abbrechen."); + Assert.That(stand.Metadata!.Error, Is.Null); + + var filme = await client.GetFromJsonAsync>("/api/v1/movies"); + Assert.That(filme!.Total, Is.EqualTo(1), "Ein Film, nicht zwei."); + Assert.That(filme.Items[0].Title, Is.EqualTo("Ich einfach unverbesserlich")); + Assert.That(filme.Items[0].Files, Has.Count.EqualTo(2), + "Beide Dateien hängen am überlebenden Eintrag — keine darf verlorengehen."); + } + + /// Dasselbe für Serien. + [Test] + public async Task Zwei_Namen_fuer_dieselbe_Serie_werden_zusammengelegt() + { + Datei("Dark/Staffel 1/Dark - S01E01.mkv"); + Datei("Dark (2017)/Staffel 1/Dark (2017) - S01E02.mkv"); + + var anbieter = new VorgegebenerAnbieter + { + Serie = new SeriesMetadata(70523, "Dark", 2017, "Eine Höhle.", null, null), + }; + + using var server = MitAnbieter(anbieter); + var (client, bibliothek) = await Einrichten(server, "Shows"); + var stand = await Scannen(client, bibliothek); + + Assert.That(stand.State, Is.EqualTo("finished")); + + var serien = await client.GetFromJsonAsync>("/api/v1/series"); + Assert.That(serien!.Total, Is.EqualTo(1)); + Assert.That(serien.Items[0].EpisodeCount, Is.EqualTo(2), + "Beide Folgen hängen an der überlebenden Serie."); + } + /// Was die Quelle nicht kennt, bleibt trotzdem in der Mediathek. [Test] public async Task Ein_Film_ohne_Treffer_behaelt_seinen_Dateinamen() diff --git a/server/tests/Flaro.Core.Tests/FileNameTests.cs b/server/tests/Flaro.Core.Tests/FileNameTests.cs index 2c1ea0c..65f67b4 100644 --- a/server/tests/Flaro.Core.Tests/FileNameTests.cs +++ b/server/tests/Flaro.Core.Tests/FileNameTests.cs @@ -165,3 +165,36 @@ public class MedienDateienTests Assert.That(MediaExtensions.IsSkippedDirectory(name), Is.EqualTo(erwartet)); } } + +/// Woher der Serienname kommt, wenn der Dateiname ihn nicht nennt. +/// +/// +/// Der Fall, der aus einer Serie siebenhundert machte. Heisst eine +/// Datei nur „S01E01.mkv", steht vor der Folgennummer nichts — und bis dahin +/// wurde der ganze Dateiname zum Serientitel. Zwanzig Folgen ergaben zwanzig +/// Serien namens „S01E01", „S01E02", und so weiter. +/// +public class SerieAusOrdnerTests +{ + [TestCase("Dark/Staffel 1/S01E01.mkv", "Dark")] + [TestCase("Dark/Season 2/S02E05.mkv", "Dark")] + [TestCase("Doctor Who (2005)/Staffel 12/S12E04.mkv", "Doctor Who")] + [TestCase("Serien/Dark/S01E01.mkv", "Dark")] + [TestCase("Dark/Specials/S00E01.mkv", "Dark")] + public void Ohne_Namen_in_der_Datei_gilt_der_Ordner(string pfad, string erwartet) => + Assert.That(FileNames.ParseEpisode(pfad)?.SeriesTitle, Is.EqualTo(erwartet)); + + /// Steht er im Dateinamen, hat er Vorrang — er ist genauer. + [Test] + public void Der_Dateiname_sticht_den_Ordner() => + Assert.That(FileNames.ParseEpisode("Irgendwas/Staffel 1/Dark - S01E01 - Geheimnisse.mkv")?.SeriesTitle, + Is.EqualTo("Dark")); + + [Test] + public void Staffel_und_Folge_bleiben_richtig() + { + var name = FileNames.ParseEpisode("Dark/Staffel 2/S02E05.mkv"); + Assert.That(name!.Season, Is.EqualTo(2)); + Assert.That(name.Episode, Is.EqualTo(5)); + } +} diff --git a/web/app/src/api.ts b/web/app/src/api.ts index b8ec504..01d93db 100644 --- a/web/app/src/api.ts +++ b/web/app/src/api.ts @@ -520,13 +520,17 @@ export const bildHochladen = async ( }; export type Pruefeintrag = { - kind: 'movie' | 'episode'; + kind: 'movie' | 'series'; id: string; title: string; year: number | null; posterImage: string | null; - /** `null` heisst: wartet. Ein Zeitpunkt in der Zukunft heisst: geplant. */ + /** `null` heisst: wartet — oder bei einer Serie: die Folgen liegen verschieden. */ publishAt: string | null; path: string | null; matched: boolean; + /** Wie viele Titel dahinterstecken; bei einer Serie die Zahl der Folgen. */ + count: number; + /** Worauf eine Freigabe wirkt. */ + ids: string[]; }; export const zuPruefen = () => anfrage('/review'); diff --git a/web/app/src/dialog.ts b/web/app/src/dialog.ts index e717abb..63f9253 100644 --- a/web/app/src/dialog.ts +++ b/web/app/src/dialog.ts @@ -10,7 +10,7 @@ import { ordner, type Ordnerstand } from './api'; * Vorlesesoftware kündigt ihn als Dialog an. Jede Zeile, die das selbst * baut, ist eine Zeile, die es schlechter kann. */ -export type Feldart = 'text' | 'password' | 'number' | 'ordner'; +export type Feldart = 'text' | 'password' | 'number' | 'ordner' | 'zeitpunkt'; export type Dialogfeld = { name: string; @@ -23,6 +23,8 @@ export type Dialogfeld = { auswahl?: { wert: string; text: string }[]; /** Ein Schalter statt eines Feldes. */ schalter?: boolean; + /** Frühester erlaubter Zeitpunkt — nur bei `art: 'zeitpunkt'`. */ + min?: string; }; /** Liest einen Feldwert — nie `undefined`, ein unbekannter Name ergibt „". @@ -104,6 +106,19 @@ export function dialog(optionen: { block.prepend(e); titel.remove(); block.append(titel); + } else if (f.art === 'zeitpunkt') { + // **Der Wähler des Browsers, nicht ein Textfeld mit Formvorschrift.** + // Vorher stand dort „Form: 2026-12-24 20:15" als Hinweis — und jeder + // Tippfehler darin war erst nach dem Absenden zu sehen. `datetime-local` + // bringt Kalender und Uhr mit, kennt die Zeitzone des Geräts und lässt + // sich mit den Pfeiltasten bedienen. + const e = document.createElement('input'); + e.className = 'feld'; + e.type = 'datetime-local'; + e.value = f.wert ?? ''; + if (f.min) e.min = f.min; + eingaben.set(f.name, e); + block.appendChild(e); } else if (f.art === 'ordner') { const e = document.createElement('input'); e.className = 'feld'; diff --git a/web/app/src/style.css b/web/app/src/style.css index 0ee6a27..aec5134 100644 --- a/web/app/src/style.css +++ b/web/app/src/style.css @@ -938,3 +938,28 @@ h2 { font-size: var(--t-abschnitt); font-weight: 700; letter-spacing: -.015em; m } .pruefzeile .pruef-bild img { width: 100%; height: 100%; object-fit: cover; display: block; } .pruefzeile .knopfreihe { align-items: center; } + +/* Der Zeitpunktwähler — der Browser bringt Kalender und Uhr mit, aber nicht + die Farben. Ohne das eigene Farbschema wäre das Symbol unsichtbar dunkel. */ +input[type="datetime-local"].feld { + color-scheme: dark; + min-height: 38px; +} +input[type="datetime-local"].feld::-webkit-calendar-picker-indicator { + filter: invert(1) opacity(.55); + cursor: pointer; +} +input[type="datetime-local"].feld::-webkit-calendar-picker-indicator:hover { + filter: invert(1) opacity(.9); +} + +/* Die Mehrfachauswahl in der Prüfliste. */ +.pruefzeile input[type="checkbox"] { + width: 17px; height: 17px; flex: none; + accent-color: var(--accent); +} +.reihe-kopf .knopfreihe .bleiben { + display: flex; align-items: center; gap: 6px; + color: var(--text-dim); font-size: var(--t-normal); +} +.reihe-kopf .knopfreihe .bleiben input { accent-color: var(--accent); width: 16px; height: 16px; } diff --git a/web/app/src/views/admin.ts b/web/app/src/views/admin.ts index 857b1f4..d414112 100644 --- a/web/app/src/views/admin.ts +++ b/web/app/src/views/admin.ts @@ -178,32 +178,110 @@ function pruefbereich(geaendert: () => void): HTMLElement { const liste = document.createElement('div'); liste.className = 'einstellungen'; - const alle = knopf('Alle veröffentlichen', 'btn btn-primary'); - alle.onclick = async () => { - alle.disabled = true; - try { await veroeffentlichen(); await laden(); geaendert(); } - catch (fehler) { liste.prepend(fehlerzeile((fehler as Error).message)); alle.disabled = false; } + /** Was gerade angehakt ist. Als Menge und nicht am DOM abgelesen: beim + * Neuladen bleibt die Auswahl damit erhalten. */ + const gewaehlt = new Set(); + let stand: Pruefeintrag[] = []; + + const alleHaken = document.createElement('label'); + alleHaken.className = 'bleiben'; + const alleKasten = document.createElement('input'); + alleKasten.type = 'checkbox'; + alleHaken.append(alleKasten, document.createTextNode(' Alle')); + alleKasten.onchange = () => { + gewaehlt.clear(); + if (alleKasten.checked) stand.forEach(e => gewaehlt.add(e.id)); + zeichnen(); }; - bereich.append(abschnitt('Zu prüfen', [alle]), liste); + const freigeben = knopf('Veröffentlichen', 'btn btn-primary'); + freigeben.onclick = () => void handeln(undefined); + + const planen = knopf('Planen…', 'btn'); + planen.onclick = () => dialog({ + titel: `${betroffene().length} Titel planen`, + text: 'Ab diesem Zeitpunkt erscheinen sie für alle. Es läuft dafür nichts ' + + 'im Hintergrund — die Sichtbarkeit wird bei jeder Abfrage gerechnet und ' + + 'stimmt deshalb auch, wenn der Server gerade nicht lief.', + felder: [{ + name: 'wann', beschriftung: 'Zeitpunkt', art: 'zeitpunkt', + wert: inEingabeform(new Date(Date.now() + 86400000)), + min: inEingabeform(new Date()), + }], + bestaetigen: 'Planen', + tun: async wert => { + const wann = new Date(wert('wann')); + if (Number.isNaN(wann.getTime())) throw new Error('Das ist kein Zeitpunkt.'); + await handeln(wann.toISOString()); + }, + }); + + bereich.append(abschnitt('Zu prüfen', [alleHaken, freigeben, planen]), liste); void laden(); - async function laden(): Promise { - let wartend: Pruefeintrag[]; - try { wartend = await zuPruefen(); } catch { bereich.classList.add('hidden'); return; } + /** Die Auswahl — oder alles, wenn nichts angehakt ist. */ + function betroffene(): Pruefeintrag[] { + const angehakt = stand.filter(e => gewaehlt.has(e.id)); + return angehakt.length > 0 ? angehakt : stand; + } - if (wartend.length === 0) { bereich.classList.add('hidden'); return; } + async function handeln(wann: string | undefined): Promise { + const ids = betroffene().flatMap(e => e.ids); + if (ids.length === 0) return; + + freigeben.disabled = true; + planen.disabled = true; + try { + await veroeffentlichen(ids, wann); + gewaehlt.clear(); + await laden(); + geaendert(); + } catch (fehler) { + liste.prepend(fehlerzeile((fehler as Error).message)); + } finally { + freigeben.disabled = false; + planen.disabled = false; + } + } + + async function laden(): Promise { + try { stand = await zuPruefen(); } catch { bereich.classList.add('hidden'); return; } + + if (stand.length === 0) { bereich.classList.add('hidden'); return; } bereich.classList.remove('hidden'); - alle.disabled = false; + + // Was es nicht mehr gibt, bleibt nicht angehakt. + const da = new Set(stand.map(e => e.id)); + [...gewaehlt].forEach(id => { if (!da.has(id)) gewaehlt.delete(id); }); + + zeichnen(); + } + + function zeichnen(): void { + alleKasten.checked = gewaehlt.size > 0 && gewaehlt.size === stand.length; + alleKasten.indeterminate = gewaehlt.size > 0 && gewaehlt.size < stand.length; + + const anzahl = betroffene().reduce((n, e) => n + e.count, 0); + const wieViele = gewaehlt.size > 0 ? `${anzahl} gewählt` : `alle ${anzahl}`; + freigeben.textContent = `Veröffentlichen (${wieViele})`; liste.innerHTML = ''; - wartend.forEach(e => liste.appendChild(zeileFuer(e))); + stand.forEach(e => liste.appendChild(zeileFuer(e))); } function zeileFuer(e: Pruefeintrag): HTMLElement { const d = document.createElement('div'); d.className = 'einstellung pruefzeile'; + const haken = document.createElement('input'); + haken.type = 'checkbox'; + haken.checked = gewaehlt.has(e.id); + haken.setAttribute('aria-label', `„${e.title}“ auswählen`); + haken.onchange = () => { + if (haken.checked) gewaehlt.add(e.id); else gewaehlt.delete(e.id); + zeichnen(); + }; + const bild = document.createElement('div'); bild.className = 'pruef-bild'; const adresse = bildAdresse(e.posterImage); @@ -216,53 +294,39 @@ function pruefbereich(geaendert: () => void): HTMLElement { const text = document.createElement('div'); text.className = 'zeile'; text.style.flex = '1'; + const geplant = e.publishAt ? new Date(e.publishAt) : null; - const stand = geplant + 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.matched ? 'Wartet auf Freigabe' - // Ohne Zuordnung fehlen Titel und Bilder — dann ist nicht die Freigabe - // das Nächste, sondern die Zuordnung. - : 'Wartet — und ist noch keinem Titel zugeordnet'; + : 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'; + text.innerHTML = `${esc(e.title)}${e.year ? ` (${e.year})` : ''}` - + `${esc(stand)}`; + + `${[was, lage, hinweis].filter(Boolean).map(esc).join(' · ')}`; - const frei = knopf('Veröffentlichen', 'btn'); - frei.onclick = async () => { - frei.disabled = true; - try { await veroeffentlichen([e.id]); await laden(); geaendert(); } - catch (fehler) { d.appendChild(fehlerzeile((fehler as Error).message)); frei.disabled = false; } + const nur = knopf(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((fehler as Error).message)); nur.disabled = false; } }; - const planen = knopf('Planen', 'btn'); - planen.onclick = () => dialog({ - titel: `„${e.title}" planen`, - text: 'Ab diesem Zeitpunkt erscheint der Titel für alle. ' - + 'Es läuft dafür nichts im Hintergrund — die Sichtbarkeit wird bei ' - + 'jeder Abfrage gerechnet und stimmt deshalb auch, wenn der Server ' - + 'gerade nicht lief.', - felder: [{ - name: 'wann', beschriftung: 'Zeitpunkt', art: 'text', - wert: inEingabeform(geplant ?? new Date(Date.now() + 86400000)), - hinweis: 'Form: 2026-12-24 20:15', - }], - bestaetigen: 'Planen', - tun: async wert => { - const wann = new Date(wert('wann').replace(' ', 'T')); - if (Number.isNaN(wann.getTime())) throw new Error('Das ist kein Zeitpunkt.'); - await veroeffentlichen([e.id], wann.toISOString()); - await laden(); - }, - }); - const oben = document.createElement('div'); oben.className = 'knopfreihe'; - oben.append(bild, text, frei, planen); + oben.append(haken, bild, text, nur); if (geplant) { const zurueck = knopf('Zurückziehen', 'btn'); zurueck.onclick = async () => { zurueck.disabled = true; - try { await zurueckziehen([e.id]); await laden(); } + try { await zurueckziehen(e.ids); await laden(); } catch (fehler) { d.appendChild(fehlerzeile((fehler as Error).message)); zurueck.disabled = false; } }; oben.appendChild(zurueck); @@ -280,11 +344,11 @@ function pruefbereich(geaendert: () => void): HTMLElement { return d; } - /** „2026-12-24 20:15" — ohne Zeitzone, weil man sie eintippt, nicht liest. */ + /** Die Form, die `datetime-local` erwartet — Ortszeit ohne Zone. */ function inEingabeform(d: Date): string { const z = (n: number) => String(n).padStart(2, '0'); - return `${d.getFullYear()}-${z(d.getMonth() + 1)}-${z(d.getDate())} ` - + `${z(d.getHours())}:${z(d.getMinutes())}`; + return `${d.getFullYear()}-${z(d.getMonth() + 1)}-${z(d.getDate())}` + + `T${z(d.getHours())}:${z(d.getMinutes())}`; } return bereich;