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;