diff --git a/docs/NAS-EINRICHTEN.md b/docs/NAS-EINRICHTEN.md new file mode 100644 index 0000000..df15e2d --- /dev/null +++ b/docs/NAS-EINRICHTEN.md @@ -0,0 +1,249 @@ +# Flaro auf dem NAS einrichten + +Für ein UGREEN-NAS mit der mitgelieferten Docker-App, und für jedes andere +System, auf dem Docker Compose läuft. + +## Die kurze Antwort auf „ein Image oder mehrere?" + +**Ein einziges.** Die Weboberfläche wird beim Bauen mit hineingelegt und vom +Server selbst ausgeliefert; es gibt keinen zweiten Container, keine Datenbank +daneben, keinen Proxy davor. Ein Container, drei Volumes, fertig. + +``` +flaro: 231 MB +├── .NET-Laufzeit + Flaro +├── wwwroot/ die Weboberfläche +└── ffmpeg + VAAPI-Treiber +``` + +--- + +## Schritt 1: Die Architektur bestimmen + +**Das ist der Schritt, den man am leichtesten überspringt und am teuersten +bezahlt.** Ein Image für `arm64` läuft auf einem Intel-NAS nicht, und die +Fehlermeldung (`exec format error`) sagt das nicht deutlich. + +Auf dem NAS, über SSH oder die Konsole der Docker-App: + +```bash +uname -m +``` + +| Ausgabe | Architektur | Was du bauen musst | +|---|---|---| +| `x86_64` | amd64 | `--platform linux/amd64` | +| `aarch64` | arm64 | `--platform linux/arm64` | + +Die meisten UGREEN-Geräte mit Intel-Prozessor (N100, i5) melden `x86_64`. +Ein Mac baut ohne Angabe **arm64** — also gehört die Angabe dazu. + +--- + +## Schritt 2: Das Image auf das NAS bringen + +Drei Wege. Der erste ist der bequemste, der zweite braucht keine Registry. + +### Weg A — über die Registry deines Gitea (empfohlen) + +Dein Gitea unter `git.scarriffle.com` bringt eine Container-Registry mit. +Einmal einrichten, danach ist jedes Update ein `docker compose pull`. + +Auf dem Arbeitsrechner: + +```bash +docker login git.scarriffle.com +``` + +```bash +cd ~/Projects_Coding/Flixy +docker buildx build --platform linux/amd64 \ + -f server/Dockerfile \ + -t git.scarriffle.com/scarriffle/flaro:latest \ + --push . +``` + +`--push` lädt direkt hoch; ein getrenntes `docker push` entfällt. + +Auf dem NAS dann in der Compose-Datei: + +```yaml +image: git.scarriffle.com/scarriffle/flaro:latest +``` + +Ist das Paket in Gitea **privat**, muss sich auch das NAS anmelden: + +```bash +docker login git.scarriffle.com +``` + +Einfacher ist, das Paket in Gitea auf öffentlich zu stellen — unter +*Pakete → flaro → Einstellungen*. Der Quelltext ist ohnehin offen; das Image +enthält nichts, was es nicht auch tut. + +### Weg B — als Datei hochladen + +Ohne Registry, dafür bei jedem Update von Hand. + +```bash +docker buildx build --platform linux/amd64 \ + -f server/Dockerfile -t flaro:latest --load . +``` + +```bash +docker save flaro:latest | gzip > flaro.tar.gz +``` + +Das ergibt rund **230 MB** — gemessen, nicht geschätzt. Das `gzip` bringt +dabei fast nichts: die Ebenen eines Docker-Images sind bereits gepackt. +Die Datei über die Dateiverwaltung auf das NAS legen, dann dort: + +```bash +docker load < /volume1/docker/flaro.tar.gz +``` + +In der Compose-Datei steht dann schlicht `image: flaro:latest`. + +### Weg C — auf dem NAS bauen + +Geht, lohnt sich aber selten: der Bau braucht den Quelltext, Node, das +.NET-SDK als Zwischenschritt und ordentlich Arbeitsspeicher. Die Docker-App +von UGREEN kann `build:` in einer Compose-Datei ausserdem nicht immer. + +--- + +## Schritt 3: Die Compose-Datei + +```yaml +services: + flaro: + image: git.scarriffle.com/scarriffle/flaro:latest + container_name: flaro + restart: unless-stopped + + ports: + - "8096:8080" + + environment: + - TZ=Europe/Zurich + # Schreibt Plakate und NFO neben die Mediendateien. Ohne das bleibt + # der Medienordner unangetastet. + - FLARO_WRITE_SIDECARS=true + + volumes: + # Datenbank und Konfiguration. **Das ist das Volume, das du sichern + # musst** — darin stehen Benutzer, Zuordnungen und Sehfortschritt. + - /volume1/docker/flaro/config:/config + # Heruntergeladene Plakate. Verlierbar: wird neu geholt. + - /volume1/docker/flaro/cache:/cache + # Umgepackte Dateien. Ebenfalls verlierbar, kann aber gross werden. + - /volume1/docker/flaro/transcode:/transcode + + # Die Medien. Links der Pfad auf dem NAS, rechts der im Container — + # und **rechts** ist der, den du in Flaro einträgst. + - /volume1/Medien/Filme:/media/Filme + - /volume1/Medien/Serien:/media/Serien + + # Hardware-Beschleunigung. Weglassen, wenn das NAS keine hat. + devices: + - /dev/dri:/dev/dri +``` + +### Rechte + +Der Container läuft als Benutzer `1000:1000` — der erste Benutzer auf fast +jedem Linux und auf den üblichen NAS-Geräten. Gehören deine Ordner jemand +anderem, setz es ausdrücklich: + +```yaml + user: "1001:100" +``` + +Welche Kennung gilt, sagt auf dem NAS: + +```bash +id +``` + +### Der Port + +`8096` ist frei gewählt. Läuft dort schon Jellyfin, nimm etwas anderes — +links die Zahl, unter der du Flaro erreichst, rechts bleibt `8080`. + +--- + +## Schritt 4: Starten und einrichten + +```bash +docker compose up -d +``` + +Dann im Browser `http://:8096` öffnen. Es erscheint der +Einrichtungsassistent: + +1. **Administratorkonto** anlegen — Benutzername und Passwort, mindestens + acht Zeichen. +2. Unter **Verwaltung → Mediatheken** eine Mediathek hinzufügen. Beim Ordner + gehört der Pfad **im Container** hinein, also `/media/Filme`, nicht + `/volume1/Medien/Filme`. +3. **Scannen.** Der Scan liest die Dateien, danach holt Flaro die Metadaten. +4. Unter **Verwaltung → Server → Metadaten** die Sprache einstellen, etwa + `de-DE`. Danach einmal **Metadaten auffrischen** — vorhandene Titel + ändern sich sonst nicht. + +--- + +## Sichern + +Was unersetzlich ist, liegt in **einem** Verzeichnis: + +``` +/volume1/docker/flaro/config/ +``` + +Darin: die SQLite-Datenbank mit Benutzern, Zuordnungen, Sehfortschritt und +Einstellungen. Eine Kopie dieser Datei ist die ganze Sicherung — kein +Datenbank-Dump, kein Export. + +`cache` und `transcode` dürfen weg; beides baut sich neu auf. + +**Die Mediendateien fasst Flaro nicht an.** Es liest sie, und es legt — +sofern `FLARO_WRITE_SIDECARS=true` gesetzt ist — Plakate und NFO-Dateien +daneben. Umbenannt oder gelöscht wird nie etwas; es gibt im ganzen Programm +keine Stelle, die das könnte. + +--- + +## Aktualisieren + +Mit Weg A: + +```bash +docker compose pull && docker compose up -d +``` + +Migrationen der Datenbank laufen beim Start von selbst. Ein Rückschritt auf +eine ältere Fassung ist **nicht** vorgesehen — vor einem Sprung lohnt eine +Kopie von `config/`. + +--- + +## Wenn etwas nicht geht + +```bash +docker compose logs -f flaro +``` + +| Zeichen | Ursache | +|---|---| +| `exec format error` | falsche Architektur — siehe Schritt 1 | +| Mediathek bleibt leer | der Pfad in Flaro ist der des NAS statt der im Container | +| „Zugriff verweigert" im Protokoll | Rechte, siehe `user:` oben | +| Keine Plakate, Titel wie Dateinamen | kein TMDB-Zugang oder kein Netz | +| Hardware-Beschleunigung greift nicht | `/dev/dri` nicht durchgereicht | + +Ein Gesundheitsendpunkt ohne Anmeldung: + +```bash +curl http://:8096/health +``` diff --git a/server/src/Flaro.Api/Auth/TokenAuthentication.cs b/server/src/Flaro.Api/Auth/TokenAuthentication.cs index e47bc83..3eb8ea6 100644 --- a/server/src/Flaro.Api/Auth/TokenAuthentication.cs +++ b/server/src/Flaro.Api/Auth/TokenAuthentication.cs @@ -31,6 +31,7 @@ public sealed class TokenAuthenticationHandler( { public const string SchemeName = "FlaroToken"; public const string DeviceTokenIdClaim = "flaro:device"; + public const string DeviceNameClaim = "flaro:deviceName"; /// Derselbe Zugang, nur für den Browser. /// @@ -91,6 +92,10 @@ public sealed class TokenAuthenticationHandler( new Claim(ClaimTypes.NameIdentifier, benutzer.Id.ToString()), new Claim(ClaimTypes.Name, benutzer.UserName), new Claim(DeviceTokenIdClaim, eintrag.Id.ToString()), + // Für das Dashboard: woher die Wiedergabe kommt. Der Name steht + // schon in der Geräteliste; ihn hier mitzugeben spart bei jeder + // Fortschrittsmeldung eine zweite Abfrage. + new Claim(DeviceNameClaim, eintrag.DeviceName), .. benutzer.IsAdministrator ? new[] { new Claim(ClaimTypes.Role, FlaroRoles.Administrator) } : [], ], SchemeName); diff --git a/server/src/Flaro.Api/Endpoints/Contracts.cs b/server/src/Flaro.Api/Endpoints/Contracts.cs index ff3939b..458e6eb 100644 --- a/server/src/Flaro.Api/Endpoints/Contracts.cs +++ b/server/src/Flaro.Api/Endpoints/Contracts.cs @@ -206,7 +206,17 @@ public sealed record MatchState( /// Fassung, die er gerade abspielt, ist seine Angabe genauer als die des /// Servers. Fehlt sie, nimmt der Server die aus der Datei. /// -public sealed record ProgressRequest(double PositionSeconds, double? RuntimeSeconds); +public sealed record ProgressRequest( + double PositionSeconds, double? RuntimeSeconds, + /// Die letzte Meldung einer Wiedergabe. + /// + /// + /// Nimmt den Eintrag sofort aus „läuft gerade". Ohne sie stünde ein + /// beendeter Film dort noch eine Minute, bis das Lebenszeichen ausbleibt + /// — und wer gerade den Spieler geschlossen hat, sieht sich selbst noch + /// beim Schauen zu. + /// + bool? Stopped = null); public sealed record WatchedRequest(bool Watched); public sealed record FavouriteRequest(bool Favourite); @@ -230,3 +240,13 @@ public sealed record ContinueEntry( int? Season, int? Episode, int? Year, string? PosterImage, string? BackdropImage, ItemStateResponse State); + +/// Eine laufende Wiedergabe — für das Dashboard. +public sealed record SessionResponse( + string UserName, string DeviceName, + Guid ItemId, string Title, string? SubTitle, string? PosterImage, + double PositionSeconds, double? RuntimeSeconds); + +/// Was der Server gerade tut. +public sealed record ServerStatusResponse( + int Playing, int Packaging, int Scanning, double Uptime); diff --git a/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs b/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs index 09b46b7..700efca 100644 --- a/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs @@ -1,4 +1,5 @@ using System.Security.Claims; +using Flaro.Api.Auth; using Flaro.Core.Playback; using Flaro.Data; using Microsoft.EntityFrameworkCore; @@ -37,7 +38,8 @@ public static class PlaybackEndpoints // Jede Meldung wird abgelegt — es gibt keine, die zu klein wäre. titel.MapPost("/progress", async ( - Guid id, ProgressRequest anfrage, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock) => + Guid id, ProgressRequest anfrage, ClaimsPrincipal wer, FlaroContext db, + TimeProvider clock, PlaybackSessions laufende) => { if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized(); @@ -72,6 +74,18 @@ public static class PlaybackEndpoints Nachfuehren(zustand); await db.SaveChangesAsync(); + + // **Die Meldung ist zugleich das Lebenszeichen.** Jeder Client + // meldet beim Abspielen ohnehin alle paar Sekunden; daraus das + // Dashboard zu speisen braucht kein zweites Verfahren, das ein + // Client vergessen könnte. Bleibt die Meldung aus, läuft der + // Eintrag von selbst ab. + // + // `stopped` nimmt ihn sofort heraus — sonst stünde ein beendeter + // Film noch eine Minute als „läuft". + if (anfrage.Stopped == true) laufende.Beenden(Geraet(wer)); + else await Mitschreiben(db, laufende, wer, benutzer, zustand, clock); + return Results.Ok(Abbild(zustand)); }); @@ -374,6 +388,67 @@ public static class PlaybackEndpoints private static void Nachfuehren(UserItemState zustand) => zustand.InProgress = ProgressRule.BelongsInContinueWatching(zustand); + /// Welches Gerät das ist — die Kennung seines Tokens. + /// + /// + /// Nicht der Benutzer: derselbe Mensch kann auf dem Fernseher und auf + /// dem Telefon gleichzeitig etwas laufen lassen, und beides gehört ins + /// Dashboard. Nicht der Gerätename: zwei iPhones heissen beide „iPhone". + /// + private static string Geraet(ClaimsPrincipal wer) => + wer.FindFirstValue(TokenAuthenticationHandler.DeviceTokenIdClaim) ?? "unbekannt"; + + /// Trägt die laufende Wiedergabe ins Dashboard ein. + private static async Task Mitschreiben( + FlaroContext db, PlaybackSessions laufende, ClaimsPrincipal wer, + Guid benutzer, UserItemState zustand, TimeProvider clock) + { + string titel; + string? untertitel = null; + string? plakat; + + if (zustand.MovieId is { } film) + { + var eintrag = await db.Movies.AsNoTracking() + .Where(m => m.Id == film) + .Select(m => new { m.Title, m.PosterImage }) + .FirstOrDefaultAsync(); + if (eintrag is null) return; + titel = eintrag.Title; + plakat = eintrag.PosterImage; + } + else if (zustand.EpisodeId is { } folge) + { + var eintrag = await db.Episodes.AsNoTracking() + .Where(f => f.Id == folge) + .Join(db.Series.AsNoTracking(), f => f.SeriesId, s => s.Id, (f, s) => new + { + SerienTitel = s.Title, s.PosterImage, + f.SeasonNumber, f.EpisodeNumber, FolgenTitel = f.Title, + }) + .FirstOrDefaultAsync(); + if (eintrag is null) return; + titel = eintrag.SerienTitel; + untertitel = $"S{eintrag.SeasonNumber} · F{eintrag.EpisodeNumber}" + + (eintrag.FolgenTitel is { Length: > 0 } n ? $" · {n}" : ""); + plakat = eintrag.PosterImage; + } + else return; + + laufende.Melden(new PlaybackSessions.Wiedergabe( + DeviceKey: Geraet(wer), + UserId: benutzer, + UserName: wer.FindFirstValue(ClaimTypes.Name) ?? "", + ItemId: zustand.MovieId ?? zustand.EpisodeId ?? Guid.Empty, + Title: titel, + SubTitle: untertitel, + PosterImage: plakat, + Position: zustand.Position, + Runtime: zustand.Runtime, + DeviceName: wer.FindFirstValue(TokenAuthenticationHandler.DeviceNameClaim) ?? "Unbekannt", + LastSeenAt: clock.GetUtcNow())); + } + internal static Guid? Benutzer(ClaimsPrincipal wer) => Guid.TryParse(wer.FindFirstValue(ClaimTypes.NameIdentifier), out var id) ? id : null; diff --git a/server/src/Flaro.Api/Endpoints/SystemEndpoints.cs b/server/src/Flaro.Api/Endpoints/SystemEndpoints.cs index 50c34a5..313abc1 100644 --- a/server/src/Flaro.Api/Endpoints/SystemEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/SystemEndpoints.cs @@ -20,6 +20,9 @@ public static class SystemEndpoints /// Unter welchem Schlüssel der Servername liegt. public const string NameSchlüssel = "server.name"; + /// Wann dieser Prozess angefangen hat. + private static readonly DateTimeOffset Gestartet = DateTimeOffset.UtcNow; + public static void MapSystem(this IEndpointRouteBuilder app) { app.MapGet("/api/v1/system/info", async (FlaroContext db) => @@ -43,6 +46,31 @@ public static class SystemEndpoints ]))) .AllowAnonymous(); + // **Wer schaut gerade was.** + // + // Nur für Administratoren: wer wann welchen Film anschaut, ist eine + // Auskunft über Menschen, nicht über den Server. In einem Haushalt + // mag das harmlos sein; die Vorgabe eines offenen Projekts darf es + // trotzdem nicht sein. + app.MapGet("/api/v1/system/sessions", (PlaybackSessions laufende) => + Results.Ok(laufende.Laufende().Select(w => new SessionResponse( + w.UserName, w.DeviceName, w.ItemId, w.Title, w.SubTitle, w.PosterImage, + w.Position.TotalSeconds, w.Runtime?.TotalSeconds)).ToList())) + .RequireAuthorization(FlaroPolicies.Administrator); + + // **Was der Server gerade tut.** Keine Kurven über zwei Minuten wie + // bei Plex — die bräuchten eine Zeitreihe im Speicher, und für die + // Frage „läuft gerade eine Umwandlung?" genügt eine Zahl. + app.MapGet("/api/v1/system/status", ( + PlaybackSessions laufende, PackageCoordinator packer, ScanCoordinator scans, + FlaroContext db) => + Results.Ok(new ServerStatusResponse( + Playing: laufende.Laufende().Count, + Packaging: packer.LaufendeAnzahl, + Scanning: scans.LaufendeAnzahl, + Uptime: (DateTimeOffset.UtcNow - Gestartet).TotalSeconds))) + .RequireAuthorization(FlaroPolicies.Administrator); + app.MapPut("/api/v1/system/name", async (ServerNameRequest anfrage, FlaroContext db) => { var name = anfrage.Name.Trim(); diff --git a/server/src/Flaro.Api/PackageCoordinator.cs b/server/src/Flaro.Api/PackageCoordinator.cs index 7de433d..6a53636 100644 --- a/server/src/Flaro.Api/PackageCoordinator.cs +++ b/server/src/Flaro.Api/PackageCoordinator.cs @@ -49,6 +49,9 @@ public sealed class PackageCoordinator( public PackageState? State(Guid fileId) => _zustand.GetValueOrDefault(fileId); + /// Wie viele Umwandlungen gerade laufen — für das Dashboard. + public int LaufendeAnzahl => _zustand.Values.Count(z => z.Status == "running"); + /// Wohin die umgepackte Datei kommt. /// /// diff --git a/server/src/Flaro.Api/PlaybackSessions.cs b/server/src/Flaro.Api/PlaybackSessions.cs new file mode 100644 index 0000000..ccaad9d --- /dev/null +++ b/server/src/Flaro.Api/PlaybackSessions.cs @@ -0,0 +1,69 @@ +using System.Collections.Concurrent; + +namespace Flaro.Api; + +/// Was gerade läuft, je Gerät. +/// +/// +/// +/// Im Arbeitsspeicher, nicht in der Datenbank. „Wer schaut gerade +/// was" ist eine Frage über den Augenblick; sie zu speichern hiesse, nach +/// einem Neustart Wiedergaben anzuzeigen, die es nicht mehr gibt. Fällt der +/// Container aus, ist die Antwort ohnehin „niemand". +/// +/// +/// Abgeleitet aus den Fortschrittsmeldungen, nicht aus einer eigenen +/// Anmeldung am Spieler. Jeder Client meldet beim Abspielen ohnehin alle +/// paar Sekunden, wo er steht — wer das als Lebenszeichen liest, braucht +/// kein zweites Verfahren, das ein Client vergessen könnte. Bleibt die +/// Meldung aus, läuft der Eintrag von selbst ab. +/// +/// +public sealed class PlaybackSessions(TimeProvider clock) +{ + /// Wie lange eine Wiedergabe ohne Meldung als laufend gilt. + /// + /// + /// Die Clients melden alle zehn Sekunden. Eine Minute lässt Raum für + /// einen Aussetzer, ohne dass eine längst beendete Wiedergabe noch + /// minutenlang im Dashboard steht. + /// + public static readonly TimeSpan Lebensdauer = TimeSpan.FromMinutes(1); + + private readonly ConcurrentDictionary _laufend = new(); + + public sealed record Wiedergabe( + string DeviceKey, + Guid UserId, + string UserName, + Guid ItemId, + string Title, + string? SubTitle, + string? PosterImage, + TimeSpan Position, + TimeSpan? Runtime, + string DeviceName, + DateTimeOffset LastSeenAt); + + /// Hält fest, dass dieses Gerät gerade etwas abspielt. + public void Melden(Wiedergabe wiedergabe) => + _laufend[wiedergabe.DeviceKey] = wiedergabe; + + /// Nimmt eine Wiedergabe heraus — beim Beenden. + public void Beenden(string deviceKey) => _laufend.TryRemove(deviceKey, out _); + + /// Was gerade läuft. Abgelaufenes fällt dabei heraus. + public IReadOnlyList Laufende() + { + var grenze = clock.GetUtcNow() - Lebensdauer; + + // Beiläufig aufräumen. Ein eigener Hintergrunddienst für eine + // Handvoll Einträge wäre Aufwand ohne Gegenwert. + foreach (var (schluessel, eintrag) in _laufend) + { + if (eintrag.LastSeenAt < grenze) _laufend.TryRemove(schluessel, out _); + } + + return _laufend.Values.OrderBy(w => w.UserName).ThenBy(w => w.Title).ToList(); + } +} diff --git a/server/src/Flaro.Api/Program.cs b/server/src/Flaro.Api/Program.cs index 45d0c66..ec18d85 100644 --- a/server/src/Flaro.Api/Program.cs +++ b/server/src/Flaro.Api/Program.cs @@ -93,6 +93,9 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +// Im Arbeitsspeicher und nur dort: wer gerade was schaut, ist eine Frage +// ueber den Augenblick. Nach einem Neustart lautet die Antwort: niemand. +builder.Services.AddSingleton(); builder.Services.AddProblemDetails(); var app = builder.Build(); diff --git a/server/src/Flaro.Api/ScanCoordinator.cs b/server/src/Flaro.Api/ScanCoordinator.cs index f46776a..cfda58b 100644 --- a/server/src/Flaro.Api/ScanCoordinator.cs +++ b/server/src/Flaro.Api/ScanCoordinator.cs @@ -40,6 +40,10 @@ public sealed class ScanCoordinator( public ScanState? State(Guid libraryId) => _zustand.GetValueOrDefault(libraryId); + /// Wie viele Läufe gerade unterwegs sind — für das Dashboard. + public int LaufendeAnzahl => + _zustand.Values.Count(z => z.Status is "running" or "enriching"); + public bool IsRunning(Guid libraryId) => State(libraryId)?.Status == "running"; /// Startet einen Lauf — oder sagt, dass schon einer läuft. diff --git a/server/tests/Flaro.Api.Tests/DashboardTests.cs b/server/tests/Flaro.Api.Tests/DashboardTests.cs new file mode 100644 index 0000000..5cc4aab --- /dev/null +++ b/server/tests/Flaro.Api.Tests/DashboardTests.cs @@ -0,0 +1,204 @@ +using System.Net; +using System.Net.Http.Json; +using Flaro.Api.Endpoints; + +namespace Flaro.Api.Tests; + +/// Wer gerade was schaut. +public class DashboardTests +{ + private string _bestand = ""; + + [SetUp] + public void Anlegen() => _bestand = Directory.CreateDirectory( + Path.Combine(Path.GetTempPath(), "flaro-dash-" + Guid.CreateVersion7().ToString("N"))).FullName; + + [TearDown] + public void Aufraeumen() + { + try { Directory.Delete(_bestand, recursive: true); } catch (IOException) { /* Kür */ } + } + + private void Datei(string relativ) + { + var voll = Path.Combine(_bestand, relativ); + Directory.CreateDirectory(Path.GetDirectoryName(voll)!); + File.WriteAllBytes(voll, new byte[2048]); + } + + private async Task Eingelesen(FlaroServer server, string art = "Movies") + { + var admin = await server.SetUpAndSignIn(); + var client = server.AlsAngemeldeter(admin.Token); + + var angelegt = await client.PostAsJsonAsync("/api/v1/libraries", + new CreateLibraryRequest("Prüfbestand", art, _bestand)); + angelegt.EnsureSuccessStatusCode(); + var bibliothek = (await angelegt.Content.ReadFromJsonAsync())!; + + (await client.PostAsync($"/api/v1/libraries/{bibliothek.Id}/scan", null)).EnsureSuccessStatusCode(); + for (var versuch = 0; versuch < 100; versuch++) + { + var stand = await client.GetFromJsonAsync($"/api/v1/libraries/{bibliothek.Id}/scan"); + if (stand!.State is "finished") return client; + if (stand.State is "failed") Assert.Fail($"Der Scan ist gescheitert: {stand.Error}"); + await Task.Delay(50); + } + Assert.Fail("Der Scan wurde in fünf Sekunden nicht fertig."); + return null!; + } + + private static async Task ErsterFilm(HttpClient client) => + (await client.GetFromJsonAsync>("/api/v1/movies"))!.Items[0].Id; + + /// Die Fortschrittsmeldung ist zugleich das Lebenszeichen. + /// + /// + /// Kein zweites Verfahren, das ein Client vergessen könnte: er meldet + /// beim Abspielen ohnehin alle paar Sekunden, wo er steht. + /// + [Test] + public async Task Eine_Fortschrittsmeldung_erscheint_im_Dashboard() + { + Datei("Matrix (1999)/Matrix (1999).mkv"); + using var server = new FlaroServer(); + var client = await Eingelesen(server); + var film = await ErsterFilm(client); + + await client.PostAsJsonAsync($"/api/v1/items/{film}/progress", + new ProgressRequest(600, 7200)); + + var laufend = await client.GetFromJsonAsync>("/api/v1/system/sessions"); + + Assert.Multiple(() => + { + Assert.That(laufend, Has.Count.EqualTo(1)); + Assert.That(laufend![0].Title, Is.EqualTo("Matrix")); + Assert.That(laufend[0].UserName, Is.EqualTo("guido")); + Assert.That(laufend[0].DeviceName, Is.EqualTo("iPhone")); + Assert.That(laufend[0].PositionSeconds, Is.EqualTo(600)); + }); + } + + /// Beendet heisst sofort weg, nicht erst nach einer Minute. + /// + /// + /// Ohne das stünde ein beendeter Film noch eine Minute als „läuft" — und + /// wer gerade den Spieler geschlossen hat, sähe sich selbst beim Schauen + /// zu. + /// + [Test] + public async Task Eine_beendete_Wiedergabe_verschwindet_sofort() + { + Datei("Matrix (1999)/Matrix (1999).mkv"); + using var server = new FlaroServer(); + var client = await Eingelesen(server); + var film = await ErsterFilm(client); + + await client.PostAsJsonAsync($"/api/v1/items/{film}/progress", new ProgressRequest(600, 7200)); + await client.PostAsJsonAsync($"/api/v1/items/{film}/progress", + new ProgressRequest(900, 7200, Stopped: true)); + + var laufend = await client.GetFromJsonAsync>("/api/v1/system/sessions"); + + Assert.That(laufend, Is.Empty); + // Der Fortschritt bleibt trotzdem erhalten — nur das „läuft gerade" + // ist vorbei. + var seite = await client.GetFromJsonAsync>("/api/v1/movies"); + Assert.That(seite!.Items[0].State!.PositionSeconds, Is.EqualTo(900)); + } + + /// Eine Folge nennt Serie und Nummer, nicht nur den Folgentitel. + [Test] + public async Task Eine_Folge_erscheint_mit_Serie_und_Nummer() + { + Datei("Dark/Season 01/Dark - S01E02 - Luegen.mkv"); + using var server = new FlaroServer(); + var client = await Eingelesen(server, "Shows"); + + var serien = await client.GetFromJsonAsync>("/api/v1/series"); + var folgen = await client.GetFromJsonAsync>( + $"/api/v1/series/{serien!.Items[0].Id}/episodes"); + + await client.PostAsJsonAsync($"/api/v1/items/{folgen!.Items[0].Id}/progress", + new ProgressRequest(400, 2700)); + + var laufend = await client.GetFromJsonAsync>("/api/v1/system/sessions"); + + Assert.Multiple(() => + { + Assert.That(laufend![0].Title, Is.EqualTo("Dark")); + Assert.That(laufend[0].SubTitle, Does.Contain("S1").And.Contain("F2")); + }); + } + + /// Wer wann was schaut, geht nicht jeden an. + [Test] + public async Task Nur_Administratoren_sehen_das_Dashboard() + { + using var server = new FlaroServer(); + var admin = server.AlsAngemeldeter((await server.SetUpAndSignIn()).Token); + + (await admin.PostAsJsonAsync("/api/v1/users", + new CreateUserRequest("mitbewohner", "ein-langes-passwort", false))).EnsureSuccessStatusCode(); + var gast = server.AlsAngemeldeter( + (await server.SignIn("mitbewohner", "ein-langes-passwort", "iPad", "geraet-2")).Token); + + Assert.Multiple(async () => + { + Assert.That((await gast.GetAsync("/api/v1/system/sessions")).StatusCode, + Is.EqualTo(HttpStatusCode.Forbidden)); + Assert.That((await gast.GetAsync("/api/v1/system/status")).StatusCode, + Is.EqualTo(HttpStatusCode.Forbidden)); + }); + } + + /// Zwei Geräte desselben Menschen sind zwei Wiedergaben. + /// + /// + /// Derselbe Mensch kann auf dem Fernseher und auf dem Telefon + /// gleichzeitig etwas laufen lassen; beides gehört ins Dashboard. + /// + [Test] + public async Task Zwei_Geraete_erscheinen_getrennt() + { + Datei("Matrix (1999)/Matrix (1999).mkv"); + using var server = new FlaroServer(); + var telefon = await Eingelesen(server); + var film = await ErsterFilm(telefon); + + var fernseher = server.AlsAngemeldeter( + (await server.SignIn("guido", "ein-langes-passwort", "Apple TV", "geraet-tv")).Token); + + await telefon.PostAsJsonAsync($"/api/v1/items/{film}/progress", new ProgressRequest(100, 7200)); + await fernseher.PostAsJsonAsync($"/api/v1/items/{film}/progress", new ProgressRequest(3000, 7200)); + + var laufend = await telefon.GetFromJsonAsync>("/api/v1/system/sessions"); + + Assert.Multiple(() => + { + Assert.That(laufend, Has.Count.EqualTo(2)); + Assert.That(laufend!.Select(w => w.DeviceName), + Is.EquivalentTo(new[] { "iPhone", "Apple TV" })); + }); + } + + [Test] + public async Task Der_Serverzustand_zaehlt_was_gerade_laeuft() + { + Datei("Matrix (1999)/Matrix (1999).mkv"); + using var server = new FlaroServer(); + var client = await Eingelesen(server); + var film = await ErsterFilm(client); + + await client.PostAsJsonAsync($"/api/v1/items/{film}/progress", new ProgressRequest(60, 7200)); + var stand = await client.GetFromJsonAsync("/api/v1/system/status"); + + Assert.Multiple(() => + { + Assert.That(stand!.Playing, Is.EqualTo(1)); + Assert.That(stand.Scanning, Is.Zero, "der Scan ist längst durch"); + Assert.That(stand.Uptime, Is.GreaterThan(0)); + }); + } +} diff --git a/web/app/src/api.ts b/web/app/src/api.ts index 78364fb..b7aa853 100644 --- a/web/app/src/api.ts +++ b/web/app/src/api.ts @@ -318,10 +318,19 @@ export const naechsteFolgen = (take = 20) => anfrage>(`/home/next${abfrage({ take })}`); /** Meldet, wo man steht. Jede Meldung zählt — es gibt keine zu kleine. */ -export const fortschritt = (id: string, stelle: number, laenge?: number | null) => +/** Meldet, wo man steht. Jede Meldung zählt — es gibt keine zu kleine. + * + * `beendet` nimmt die Wiedergabe sofort aus „läuft gerade". Ohne das stünde + * ein geschlossener Spieler dort noch eine Minute. */ +export const fortschritt = ( + id: string, stelle: number, laenge?: number | null, beendet = false, +) => anfrage(`/items/${id}/progress`, { method: 'POST', - body: JSON.stringify({ positionSeconds: stelle, runtimeSeconds: laenge ?? null }), + body: JSON.stringify({ + positionSeconds: stelle, runtimeSeconds: laenge ?? null, + stopped: beendet || null, + }), }); export const gesehen = (id: string, wert: boolean) => @@ -354,6 +363,22 @@ export const empfehlungen = (take = 20) => export const metadatenAuffrischen = (id: string) => anfrage(`/libraries/${id}/refresh`, { method: 'POST' }); +/** Eine laufende Wiedergabe — nur für Administratoren sichtbar. */ +export type Wiedergabe = { + userName: string; deviceName: string; + itemId: string; title: string; subTitle: string | null; posterImage: string | null; + positionSeconds: number; runtimeSeconds: number | null; +}; + +export type Serverzustand = { + playing: number; packaging: number; scanning: number; uptime: number; +}; + +export const laufendeWiedergaben = () => + anfrage('/system/sessions'); + +export const serverzustand = () => anfrage('/system/status'); + export const servernameSetzen = (name: string) => anfrage('/system/name', { method: 'PUT', body: JSON.stringify({ name }) }); diff --git a/web/app/src/dialog.ts b/web/app/src/dialog.ts new file mode 100644 index 0000000..0004278 --- /dev/null +++ b/web/app/src/dialog.ts @@ -0,0 +1,196 @@ +import { icons } from './icons'; +import { knopf } from './util'; + +/* Dialoge über dem Inhalt statt Blöcken darin. + * + * **Mit dem `` des Browsers, nicht mit einem nachgebauten.** Der + * kann, was ein `
` erst lernen müsste: Escape schliesst, der + * Hintergrund wird unbedienbar, der Tastaturfokus bleibt gefangen, und + * 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'; + +export type Dialogfeld = { + name: string; + beschriftung: string; + art?: Feldart; + wert?: string; + platzhalter?: string; + hinweis?: string; + /** Eine Auswahl statt eines Feldes. */ + auswahl?: { wert: string; text: string }[]; + /** Ein Schalter statt eines Feldes. */ + schalter?: boolean; +}; + +/** Liest einen Feldwert — nie `undefined`, ein unbekannter Name ergibt „". + * + * Als Funktion statt als Objekt: ein Tippfehler im Feldnamen gäbe sonst + * stillschweigend `undefined` zurück und landete so im Server. */ +export type Dialogwerte = (name: string) => string; + +/** Zeigt einen Dialog mit Feldern und gibt zurück, was eingetippt wurde. + * + * `null` heisst abgebrochen — nicht „leer". Die beiden zu verwechseln hiesse, + * ein Abbrechen wie ein Speichern zu behandeln. */ +export function dialog(optionen: { + titel: string; + text?: string; + felder?: Dialogfeld[]; + bestaetigen: string; + /** Färbt den Knopf rot — für alles, was etwas wegnimmt. */ + gefaehrlich?: boolean; + /** Wird mit den Werten gerufen; wirft sie, bleibt der Dialog offen. */ + tun: (wert: Dialogwerte) => Promise; +}): void { + const d = document.createElement('dialog'); + d.className = 'dlg'; + + const kopf = document.createElement('div'); + kopf.className = 'dlg-kopf'; + const h = document.createElement('h2'); + h.textContent = optionen.titel; + const zu = document.createElement('button'); + zu.className = 'dlg-zu'; + zu.innerHTML = icons.schliessen; + zu.setAttribute('aria-label', 'Schliessen'); + zu.onclick = () => d.close(); + kopf.append(h, zu); + + const koerper = document.createElement('div'); + koerper.className = 'dlg-koerper'; + + if (optionen.text) { + const p = document.createElement('p'); + p.className = 'hinweis'; + p.textContent = optionen.text; + koerper.appendChild(p); + } + + const eingaben = new Map(); + + (optionen.felder ?? []).forEach(f => { + const block = document.createElement('label'); + block.className = 'dlg-feld'; + + const titel = document.createElement('span'); + titel.className = 'dlg-beschriftung'; + titel.textContent = f.beschriftung; + block.appendChild(titel); + + if (f.auswahl) { + const wahl = document.createElement('select'); + wahl.className = 'feld'; + f.auswahl.forEach(o => { + const e = document.createElement('option'); + e.value = o.wert; e.textContent = o.text; + wahl.appendChild(e); + }); + if (f.wert) wahl.value = f.wert; + eingaben.set(f.name, wahl); + block.appendChild(wahl); + } else if (f.schalter) { + const e = document.createElement('input'); + e.type = 'checkbox'; + e.checked = f.wert === 'true'; + eingaben.set(f.name, e); + // Der Schalter steht vor seiner Beschriftung, nicht darunter. + block.classList.add('waagerecht'); + block.prepend(e); + titel.remove(); + block.append(titel); + } else { + const e = document.createElement('input'); + e.className = 'feld'; + e.type = f.art ?? 'text'; + e.value = f.wert ?? ''; + e.placeholder = f.platzhalter ?? ''; + eingaben.set(f.name, e); + block.appendChild(e); + } + + if (f.hinweis) { + const kleingedrucktes = document.createElement('span'); + kleingedrucktes.className = 'winzig'; + kleingedrucktes.textContent = f.hinweis; + block.appendChild(kleingedrucktes); + } + + koerper.appendChild(block); + }); + + const fehlerplatz = document.createElement('div'); + koerper.appendChild(fehlerplatz); + + const fuss = document.createElement('div'); + fuss.className = 'dlg-fuss'; + const abbrechen = knopf('Abbrechen', 'btn'); + abbrechen.onclick = () => d.close(); + const los = knopf(optionen.bestaetigen, + optionen.gefaehrlich ? 'btn btn-gefahr' : 'btn btn-primary'); + fuss.append(abbrechen, los); + + los.onclick = async () => { + fehlerplatz.innerHTML = ''; + los.disabled = true; + const gesammelt = new Map(); + eingaben.forEach((e, name) => { + gesammelt.set(name, e instanceof HTMLInputElement && e.type === 'checkbox' + ? String(e.checked) + : e.value.trim()); + }); + + try { + await optionen.tun(name => gesammelt.get(name) ?? ''); + d.close(); + } catch (fehler) { + // **Offen bleiben.** Einen Dialog bei einem Fehler zu schliessen + // hiesse, die Eingabe wegzuwerfen und die Meldung dorthin zu + // schreiben, wo gerade niemand hinsieht. + const m = document.createElement('div'); + m.className = 'fehler'; + m.textContent = (fehler as Error).message; + fehlerplatz.appendChild(m); + los.disabled = false; + } + }; + + d.append(kopf, koerper, fuss); + // Der Dialog räumt sich selbst weg — sonst sammeln sich im Dokument + // beliebig viele geschlossene an. + d.addEventListener('close', () => d.remove()); + // Ein Klick auf den Hintergrund schliesst, ein Klick in den Dialog nicht. + d.addEventListener('click', e => { if (e.target === d) d.close(); }); + + document.body.appendChild(d); + d.showModal(); + + const erstes = koerper.querySelector('input, select'); + erstes?.focus(); + + // Enter im Feld bestätigt — ausser im mehrzeiligen Fall, den es hier + // nicht gibt. + koerper.addEventListener('keydown', e => { + if (e.key === 'Enter' && !(e.target as HTMLElement).matches('select')) { + e.preventDefault(); + los.click(); + } + }); +} + +/** Fragt einmal nach — für alles, was etwas wegnimmt. */ +export function nachfragen(optionen: { + titel: string; + text: string; + bestaetigen: string; + tun: () => Promise; +}): void { + dialog({ + titel: optionen.titel, + text: optionen.text, + bestaetigen: optionen.bestaetigen, + gefaehrlich: true, + tun: () => optionen.tun(), + }); +} diff --git a/web/app/src/icons.ts b/web/app/src/icons.ts index 27cd659..1375b37 100644 --- a/web/app/src/icons.ts +++ b/web/app/src/icons.ts @@ -34,6 +34,7 @@ export const icons = { + ''), abmelden: rahmen('' + ''), + schliessen: rahmen(''), stern: rahmen(''), } as const; diff --git a/web/app/src/style.css b/web/app/src/style.css index 184279b..bdcafb6 100644 --- a/web/app/src/style.css +++ b/web/app/src/style.css @@ -189,22 +189,58 @@ svg { width: 20px; height: 20px; display: block; flex: none; } .reiter button:hover { color: var(--text); } .reiter button.check { color: var(--accent); border-bottom-color: var(--accent); } -/* Filterzeile darunter. */ +/* Filterzeile darunter — Pillen, keine Formularfelder. + * + * **Bei Plex sieht ein Filter nicht nach Eingabe aus.** Dort stehen Wörter + * mit einem kleinen Pfeil daneben; ein umrandetes Auswahlfeld sagt „hier + * fehlt noch etwas", und das stimmt nicht — es steht ja schon etwas drin. */ .filterzeile { - display: flex; align-items: center; gap: var(--s-regular); + display: flex; align-items: center; gap: var(--s-snug); margin-bottom: var(--s-section); flex-wrap: wrap; } .filterzeile .anzahl { - background: var(--elevated); border-radius: var(--r-card); - padding: 2px 10px; font-size: 13px; color: var(--text-dim); + background: var(--elevated); border-radius: var(--r-pill); + padding: 3px 11px; font-size: 13px; color: var(--text-dim); font-variant-numeric: tabular-nums; } -.filterzeile select, .filterzeile input { - background: var(--elevated); border: 1px solid transparent; - border-radius: var(--r-control); padding: 6px 10px; color: var(--text); outline: none; + +.pille { + position: relative; display: inline-flex; align-items: center; +} +.pille select { + appearance: none; -webkit-appearance: none; + background: none; border: 0; outline: none; cursor: pointer; + color: var(--text-dim); font-weight: 500; + padding: 6px 26px 6px 11px; border-radius: var(--r-pill); +} +.pille select:hover { background: var(--elevated); color: var(--text); } +.pille select:focus-visible { box-shadow: 0 0 0 2px var(--accent); } +/* Gesetzt heisst farbig — so sieht man ohne Lesen, dass gefiltert wird. */ +.pille.gesetzt select { color: var(--accent); } +.pille::after { + content: ''; position: absolute; right: 11px; top: 50%; + width: 6px; height: 6px; margin-top: -4px; + border-right: 1.6px solid currentColor; border-bottom: 1.6px solid currentColor; + transform: rotate(45deg); color: var(--text-faint); pointer-events: none; +} +.pille.gesetzt::after { color: var(--accent); } + +.filterzeile .zuruecksetzen { + background: none; border: 0; padding: 6px 4px; + color: var(--text-muted); font-size: 13px; +} +.filterzeile .zuruecksetzen:hover { color: var(--text); text-decoration: underline; } + +.filterzeile .suchhuelle { + margin-left: auto; display: flex; align-items: center; gap: var(--s-snug); + background: var(--elevated); border: 1px solid transparent; + border-radius: var(--r-pill); padding: 0 var(--s-regular); height: 32px; + color: var(--text-muted); min-width: 220px; +} +.filterzeile .suchhuelle:focus-within { border-color: var(--accent); color: var(--text); } +.filterzeile .suchhuelle input { + flex: 1; min-width: 0; background: none; border: 0; outline: none; color: var(--text); } -.filterzeile select:focus, .filterzeile input:focus { border-color: var(--accent); } -.filterzeile .suche { margin-left: auto; min-width: 220px; } /* ---------- Kacheln ---------- */ @@ -452,6 +488,61 @@ h2 { font-size: 19px; font-weight: 700; letter-spacing: -.01em; margin: 0 0 var( } .attribution img { height: 14px; opacity: .7; } +/* Eine laufende Wiedergabe im Dashboard. */ +.einstellung.laeuft { gap: var(--s-snug); } +.laeuft-bild { + flex: none; width: 44px; aspect-ratio: 2 / 3; + border-radius: 3px; overflow: hidden; background: var(--placeholder); +} +.laeuft-bild img { width: 100%; height: 100%; object-fit: cover; } +.fortschritt.breit { position: static; height: 3px; border-radius: 2px; overflow: hidden; } + +/* ---------- Dialoge ---------- */ + +/* Das `` des Browsers: Escape schliesst, der Hintergrund ist + unbedienbar, der Fokus bleibt gefangen. Hier bleibt nur das Aussehen. */ +.dlg { + width: min(520px, calc(100vw - 2 * var(--s-section))); + padding: 0; border: 1px solid var(--line); + border-radius: var(--r-control); + background: var(--surface); color: var(--text); + box-shadow: 0 24px 64px rgba(0, 0, 0, .7); +} +.dlg::backdrop { background: rgba(0, 0, 0, .62); backdrop-filter: blur(2px); } + +.dlg-kopf { + display: flex; align-items: center; gap: var(--s-regular); + padding: var(--s-loose) var(--s-section); + border-bottom: 1px solid var(--line); +} +.dlg-kopf h2 { margin: 0; flex: 1; font-size: 17px; } +.dlg-zu { + width: 30px; height: 30px; display: grid; place-items: center; + background: none; border: 0; border-radius: 50%; color: var(--text-muted); +} +.dlg-zu:hover { background: var(--elevated); color: var(--text); } + +.dlg-koerper { + padding: var(--s-section); + display: flex; flex-direction: column; gap: var(--s-loose); + max-height: 60vh; overflow-y: auto; +} +.dlg-feld { display: flex; flex-direction: column; gap: var(--s-snug); } +.dlg-feld.waagerecht { + flex-direction: row; align-items: center; gap: var(--s-snug); cursor: pointer; +} +.dlg-feld.waagerecht input { accent-color: var(--accent); width: 15px; height: 15px; margin: 0; } +.dlg-beschriftung { font-size: 13px; color: var(--text-dim); } + +.dlg-fuss { + display: flex; justify-content: flex-end; gap: var(--s-snug); + padding: var(--s-loose) var(--s-section); + border-top: 1px solid var(--line); +} + +.btn-gefahr { background: var(--destructive); color: #fff; font-weight: 600; } +.btn-gefahr:hover { background: #C53338; } + /* Der Spieler über allem. */ .pl-fill { position: fixed; inset: 0; z-index: 100; background: #000; diff --git a/web/app/src/views/admin.ts b/web/app/src/views/admin.ts index b950ae6..21099f1 100644 --- a/web/app/src/views/admin.ts +++ b/web/app/src/views/admin.ts @@ -1,29 +1,188 @@ import { bibliotheken, bibliothekAnlegen, bibliothekUmbenennen, bibliothekLoeschen, - scanStarten, scanstand, + scanStarten, scanstand, metadatenAuffrischen, benutzer, benutzerAnlegen, metadatenEinstellung, metadatenSetzen, - type Bibliothek, + servernameSetzen, systemauskunft, + laufendeWiedergaben, serverzustand, bildAdresse, + type Bibliothek, type Wiedergabe, } from '../api'; -import { esc, knopf, feld, fehlerzeile, leer } from '../util'; +import { esc, dauer, knopf, fehlerzeile, leer } from '../util'; +import { dialog, nachfragen } from '../dialog'; +import { icon } from '../icons'; -/// `geaendert` wird gerufen, wenn sich die Mediathekenliste geändert hat — -/// die Seitenleiste steht auf ihr und muss dann neu gezeichnet werden. +/** Die Verwaltung. + * + * **Was etwas anlegt oder ändert, steht in einem Dialog**, nicht als Block + * in der Seite. Ein Formular, das immer dasteht, nimmt Platz für etwas weg, + * das man einmal im Monat braucht — und sieht aus wie eine Eingabe, die + * noch aussteht. + * + * `geaendert` wird gerufen, wenn sich die Mediathekenliste geändert hat; die + * Seitenleiste steht auf ihr und muss dann neu gezeichnet werden. */ export function verwaltung(geaendert: () => void = () => {}): HTMLElement { const wurzel = document.createElement('div'); - wurzel.append(bibliotheksbereich(geaendert), benutzerbereich(), metadatenbereich()); + wurzel.append(dashboard(), bibliotheksbereich(geaendert), benutzerbereich(), serverbereich()); return wurzel; } +/** Überschrift mit einem Knopf rechts daneben. */ +function abschnitt(titel: string, knoepfe: HTMLElement[] = []): HTMLElement { + const kopf = document.createElement('div'); + kopf.className = 'reihe-kopf'; + const h = document.createElement('h2'); + h.textContent = titel; + kopf.appendChild(h); + if (knoepfe.length) { + const rechts = document.createElement('div'); + rechts.className = 'knopfreihe'; + rechts.style.marginLeft = 'auto'; + knoepfe.forEach(k => rechts.appendChild(k)); + kopf.appendChild(rechts); + } + return kopf; +} + +/* ---------- Dashboard ---------- */ + +/** Was gerade läuft. + * + * **Fragt alle fünf Sekunden nach.** Ein dauerhaft offener Kanal wäre + * sparsamer, aber für eine Handvoll Zeilen auf einer Seite, die man selten + * offen hat, ist er der grössere Aufwand — auf beiden Seiten. */ +function dashboard(): HTMLElement { + const bereich = document.createElement('section'); + bereich.appendChild(abschnitt('Wird abgespielt')); + + const liste = document.createElement('div'); + liste.className = 'einstellungen'; + liste.innerHTML = '
'; + + const zustand = document.createElement('div'); + zustand.className = 'hinweis'; + + bereich.append(liste, zustand); + + let timer: number | undefined; + void laden(); + + // Aufhören, sobald die Seite weg ist — sonst fragt eine unsichtbare + // Verwaltung bis zum Neuladen weiter. + const waechter = new MutationObserver(() => { + if (!bereich.isConnected) { window.clearTimeout(timer); waechter.disconnect(); } + }); + waechter.observe(document.body, { childList: true, subtree: true }); + + async function laden(): Promise { + try { + const [laufend, stand] = await Promise.all([laufendeWiedergaben(), serverzustand()]); + + liste.innerHTML = ''; + if (laufend.length === 0) { + liste.appendChild(leer('Derzeit wird nichts gespielt.')); + } else { + laufend.forEach(w => liste.appendChild(wiedergabe(w))); + } + + const teile = [`Läuft seit ${laufzeit(stand.uptime)}`]; + if (stand.packaging > 0) teile.push(`${stand.packaging} Umwandlung(en) laufen`); + if (stand.scanning > 0) teile.push(`${stand.scanning} Scan(s) laufen`); + zustand.textContent = teile.join(' · '); + } catch (fehler) { + liste.innerHTML = ''; + liste.appendChild(fehlerzeile((fehler as Error).message)); + return; // Nicht weiter nachfragen, wenn es scheitert. + } + timer = window.setTimeout(() => void laden(), 5000); + } + + function wiedergabe(w: Wiedergabe): HTMLElement { + const d = document.createElement('div'); + d.className = 'einstellung laeuft'; + + const bild = document.createElement('div'); + bild.className = 'laeuft-bild'; + const adresse = bildAdresse(w.posterImage); + if (adresse) { + const img = document.createElement('img'); + img.src = adresse; + img.alt = ''; + bild.appendChild(img); + } + + const text = document.createElement('div'); + text.className = 'zeile'; + text.style.flex = '1'; + const wie = w.runtimeSeconds + ? `${dauer(w.positionSeconds)} von ${dauer(w.runtimeSeconds)}` + : dauer(w.positionSeconds); + text.innerHTML = `${esc(w.title)}` + + (w.subTitle ? `${esc(w.subTitle)}` : '') + + `${esc(w.userName)} · ${esc(w.deviceName)} · ${esc(wie)}`; + + const innen = document.createElement('div'); + innen.className = 'knopfreihe'; + innen.append(bild, text); + d.appendChild(innen); + + if (w.runtimeSeconds) { + const balken = document.createElement('div'); + balken.className = 'fortschritt breit'; + const gefuellt = document.createElement('div'); + gefuellt.style.width = `${Math.min(100, (w.positionSeconds / w.runtimeSeconds) * 100)}%`; + balken.appendChild(gefuellt); + d.appendChild(balken); + } + + return d; + } + + return bereich; +} + +/** „3 Std. 12 Min." — aus Sekunden. */ +function laufzeit(sekunden: number): string { + const tage = Math.floor(sekunden / 86400); + const stunden = Math.floor((sekunden % 86400) / 3600); + const minuten = Math.floor((sekunden % 3600) / 60); + if (tage > 0) return `${tage} Tg. ${stunden} Std.`; + if (stunden > 0) return `${stunden} Std. ${minuten} Min.`; + return `${minuten} Min.`; +} + /* ---------- Bibliotheken ---------- */ function bibliotheksbereich(geaendert: () => void): HTMLElement { const bereich = document.createElement('section'); - const h = document.createElement('h2'); - h.textContent = 'Bibliotheken'; + + const anlegen = knopf('Mediathek hinzufügen', 'btn btn-primary'); + anlegen.onclick = () => dialog({ + titel: 'Mediathek hinzufügen', + felder: [ + { name: 'name', beschriftung: 'Name', platzhalter: 'etwa Filme' }, + { + name: 'art', beschriftung: 'Art', + auswahl: [{ wert: 'Movies', text: 'Filme' }, { wert: 'Shows', text: 'Serien' }], + }, + { + name: 'pfad', beschriftung: 'Ordner', + platzhalter: '/media/Filme', + hinweis: 'Der Pfad im Container, nicht der auf dem NAS — ' + + 'also das, was in der Compose-Datei hinter dem Doppelpunkt steht.', + }, + ], + bestaetigen: 'Anlegen', + tun: async wert => { + await bibliothekAnlegen(wert('name'), wert('art'), wert('pfad')); + await laden(); + geaendert(); + }, + }); + + bereich.appendChild(abschnitt('Mediatheken', [anlegen])); const liste = document.createElement('div'); liste.className = 'einstellungen'; - bereich.append(h, liste, anlegeform()); + bereich.appendChild(liste); void laden(); async function laden(): Promise { @@ -32,8 +191,7 @@ function bibliotheksbereich(geaendert: () => void): HTMLElement { const alle = await bibliotheken(); liste.innerHTML = ''; if (alle.length === 0) { - liste.appendChild(leer('Noch keine Bibliothek. Lege unten eine an — der Pfad ist ' - + 'der **im Container**, also etwa /media/Filme, nicht der auf dem NAS.')); + liste.appendChild(leer('Noch keine Mediathek. Lege oben eine an.')); return; } alle.forEach(b => liste.appendChild(block(b))); @@ -47,79 +205,64 @@ function bibliotheksbereich(geaendert: () => void): HTMLElement { const d = document.createElement('div'); d.className = 'einstellung'; - const kopf = document.createElement('div'); - kopf.className = 'zeile'; - // „1 Filme" ist der Satz, an dem man sieht, dass niemand hingeschaut hat. const serie = b.kind === 'Shows'; + // „1 Filme" ist der Satz, an dem man sieht, dass niemand hingeschaut hat. const anzahl = b.itemCount == null ? '' : ` · ${b.itemCount} ` + (b.itemCount === 1 ? (serie ? 'Serie' : 'Film') : (serie ? 'Serien' : 'Filme')); + + const kopf = document.createElement('div'); + kopf.className = 'zeile'; kopf.innerHTML = `${esc(b.name)}` + `${esc(b.path)}${esc(anzahl)}`; d.appendChild(kopf); const stand = document.createElement('div'); stand.className = 'hinweis'; + const los = knopf('Scannen', 'btn'); - const umbenennen = knopf('Umbenennen', 'btn'); - const entfernen = knopf('Entfernen', 'btn'); - - umbenennen.onclick = () => { - umbenennen.remove(); - const eingabe = feld('Neuer Name'); - eingabe.value = b.name; - const fertig = knopf('Speichern', 'btn btn-primary'); - const reihe = document.createElement('div'); - reihe.className = 'knopfreihe'; - reihe.append(eingabe, fertig); - d.appendChild(reihe); - eingabe.focus(); - - fertig.onclick = async () => { - fertig.disabled = true; - try { - await bibliothekUmbenennen(b.id, eingabe.value.trim()); - await laden(); - geaendert(); - } catch (fehler) { - d.appendChild(fehlerzeile((fehler as Error).message)); - fertig.disabled = false; - } - }; - }; - - entfernen.onclick = async () => { - // **Einmal nachfragen.** Was hier verschwindet, ist der Eintrag und - // der Sehfortschritt aller Benutzer dazu — die Mediendateien bleiben - // liegen, aber das weiss nicht jeder, der auf den Knopf sieht. - const sicher = window.confirm( - `„${b.name}" aus Flaro entfernen?\n\n` - + 'Die Mediendateien auf der Platte bleiben unangetastet. Weg sind die ' - + 'Einträge, die Zuordnungen und der Sehfortschritt aller Benutzer dazu.'); - if (!sicher) return; - - entfernen.disabled = true; - try { - await bibliothekLoeschen(b.id); - await laden(); - geaendert(); - } catch (fehler) { - d.appendChild(fehlerzeile((fehler as Error).message)); - entfernen.disabled = false; - } - }; - los.onclick = async () => { los.disabled = true; - try { - await scanStarten(b.id); - await verfolgen(); - } catch (fehler) { stand.textContent = (fehler as Error).message; } + try { await scanStarten(b.id); await verfolgen(); } + catch (fehler) { stand.textContent = (fehler as Error).message; } los.disabled = false; }; - /** Fragt nach, bis der Lauf durch ist — der Scan läuft im Hintergrund. */ + const auffrischen = knopf('Metadaten auffrischen', 'btn'); + auffrischen.title = 'Holt Titel, Bilder und Besetzung erneut — ' + + 'nötig nach einer Sprachänderung.'; + auffrischen.onclick = () => nachfragen({ + titel: `Metadaten von „${b.name}" auffrischen?`, + text: 'Alle Titel werden erneut bei TMDB geholt — über die bekannte Kennung, ' + + 'nicht über eine neue Suche. Zuordnungen von Hand bleiben damit stehen. ' + + 'Bei vielen Titeln dauert es.', + bestaetigen: 'Auffrischen', + tun: async () => { await metadatenAuffrischen(b.id); void verfolgen(); }, + }); + + const umbenennen = knopf('Umbenennen', 'btn'); + umbenennen.onclick = () => dialog({ + titel: 'Mediathek umbenennen', + felder: [{ name: 'name', beschriftung: 'Name', wert: b.name }], + bestaetigen: 'Speichern', + tun: async wert => { + await bibliothekUmbenennen(b.id, wert('name')); + await laden(); + geaendert(); + }, + }); + + const entfernen = knopf('Entfernen', 'btn'); + entfernen.onclick = () => nachfragen({ + titel: `„${b.name}" entfernen?`, + text: 'Die Mediendateien auf der Platte bleiben unangetastet. Weg sind die ' + + 'Einträge, die Zuordnungen und der Sehfortschritt aller Benutzer dazu.', + bestaetigen: 'Entfernen', + tun: async () => { await bibliothekLoeschen(b.id); await laden(); geaendert(); }, + }); + + /** Fragt nach, bis der Lauf durch ist — er läuft im Hintergrund. */ async function verfolgen(): Promise { for (let i = 0; i < 900; i++) { const s = await scanstand(b.id); @@ -133,6 +276,8 @@ function bibliotheksbereich(geaendert: () => void): HTMLElement { else if (s.metadata) teile.push(`${s.metadata.moviesMatched + s.metadata.seriesMatched} zugeordnet`); if (s.metadata?.error) teile.push(s.metadata.error); stand.textContent = teile.join(' · '); + await laden(); + geaendert(); return; } await new Promise(r => setTimeout(r, 1000)); @@ -141,42 +286,12 @@ function bibliotheksbereich(geaendert: () => void): HTMLElement { const knoepfe = document.createElement('div'); knoepfe.className = 'knopfreihe'; - knoepfe.append(los, umbenennen, entfernen); + knoepfe.append(los, auffrischen, umbenennen, entfernen); d.append(knoepfe, stand); void verfolgen(); return d; } - function anlegeform(): HTMLElement { - const form = document.createElement('div'); - form.className = 'einstellung'; - - const name = feld('Name, etwa Filme'); - const pfad = feld('Pfad im Container, etwa /media/Filme'); - const art = document.createElement('select'); - art.className = 'field'; - art.innerHTML = ''; - const los = knopf('Anlegen', 'btn btn-primary'); - - const reihe = document.createElement('div'); - reihe.className = 'knopfreihe'; - reihe.append(name, art, pfad, los); - form.append(reihe); - - los.onclick = async () => { - form.querySelector('.error')?.remove(); - los.disabled = true; - try { - await bibliothekAnlegen(name.value.trim(), art.value, pfad.value.trim()); - name.value = ''; pfad.value = ''; - await laden(); - geaendert(); - } catch (fehler) { form.appendChild(fehlerzeile((fehler as Error).message)); } - los.disabled = false; - }; - return form; - } - return bereich; } @@ -184,28 +299,30 @@ function bibliotheksbereich(geaendert: () => void): HTMLElement { function benutzerbereich(): HTMLElement { const bereich = document.createElement('section'); - const h = document.createElement('h2'); - h.textContent = 'Benutzer'; + + const anlegen = knopf('Benutzer hinzufügen', 'btn'); + anlegen.onclick = () => dialog({ + titel: 'Benutzer hinzufügen', + felder: [ + { name: 'name', beschriftung: 'Benutzername' }, + { + name: 'passwort', beschriftung: 'Passwort', art: 'password', + hinweis: 'Mindestens acht Zeichen.', + }, + { name: 'admin', beschriftung: 'Administrator', schalter: true }, + ], + bestaetigen: 'Anlegen', + tun: async wert => { + await benutzerAnlegen(wert('name'), wert('passwort'), wert('admin') === 'true'); + await laden(); + }, + }); + + bereich.appendChild(abschnitt('Benutzer', [anlegen])); + const liste = document.createElement('div'); liste.className = 'einstellungen'; - - const name = feld('Benutzername'); - const passwort = feld('Passwort, mindestens 8 Zeichen', 'password'); - const admin = document.createElement('label'); - admin.className = 'hinweis'; - const haken = document.createElement('input'); - haken.type = 'checkbox'; - admin.append(haken, document.createTextNode(' Administrator')); - const los = knopf('Anlegen', 'btn'); - - const form = document.createElement('div'); - form.className = 'einstellung'; - const reihe = document.createElement('div'); - reihe.className = 'knopfreihe'; - reihe.append(name, passwort, admin, los); - form.append(reihe); - - bereich.append(h, liste, form); + bereich.appendChild(liste); void laden(); async function laden(): Promise { @@ -226,69 +343,104 @@ function benutzerbereich(): HTMLElement { } } - los.onclick = async () => { - form.querySelector('.error')?.remove(); - los.disabled = true; - try { - await benutzerAnlegen(name.value.trim(), passwort.value, haken.checked); - name.value = ''; passwort.value = ''; haken.checked = false; - await laden(); - } catch (fehler) { form.appendChild(fehlerzeile((fehler as Error).message)); } - los.disabled = false; - }; - return bereich; } -/* ---------- Metadaten ---------- */ +/* ---------- Server ---------- */ -function metadatenbereich(): HTMLElement { +function serverbereich(): HTMLElement { const bereich = document.createElement('section'); - const h = document.createElement('h2'); - h.textContent = 'Metadaten'; - const block = document.createElement('div'); - block.className = 'einstellung'; - bereich.append(h, block); + bereich.appendChild(abschnitt('Server')); + + const liste = document.createElement('div'); + liste.className = 'einstellungen'; + bereich.appendChild(liste); void (async () => { try { - const stand = await metadatenEinstellung(); + const [auskunft, stand] = await Promise.all([systemauskunft(), metadatenEinstellung()]); + liste.innerHTML = ''; - const auskunft = document.createElement('div'); - auskunft.className = 'hinweis'; - auskunft.textContent = stand.usingProjectKey - ? 'Es wird der mitgelieferte TMDB-Zugang benutzt. Ein eigener löst ihn ab.' - : stand.hasApiKey ? 'Ein eigener TMDB-Zugang ist hinterlegt.' - : 'Kein Zugang hinterlegt — die Titel bleiben bei den Dateinamen.'; + liste.appendChild(zeile( + 'Name', auskunft.serverName ?? '—', + 'So nennen ihn die Apps und die Kopfzeile.', + () => dialog({ + titel: 'Server umbenennen', + felder: [{ name: 'name', beschriftung: 'Name', wert: auskunft.serverName ?? '' }], + bestaetigen: 'Speichern', + tun: async wert => { await servernameSetzen(wert('name')); location.reload(); }, + }))); - const schluessel = feld('Eigener TMDB-Zugang (Read Access Token oder API Key)', 'password'); - const sprache = feld('Sprache, etwa de-DE'); - sprache.value = stand.language; - const los = knopf('Speichern', 'btn'); + liste.appendChild(zeile( + 'Metadaten', + stand.usingProjectKey ? `Mitgelieferter TMDB-Zugang · ${stand.language}` + : stand.hasApiKey ? `Eigener TMDB-Zugang · ${stand.language}` + : 'Kein Zugang hinterlegt', + stand.usingProjectKey + ? 'Ein eigener Zugang löst den mitgelieferten ab.' + : 'Nach einer Sprachänderung die Metadaten auffrischen — ' + + 'vorhandene Titel ändern sich sonst nicht.', + () => dialog({ + titel: 'Metadaten', + felder: [ + { + name: 'schluessel', beschriftung: 'Eigener TMDB-Zugang', art: 'password', + platzhalter: 'Read Access Token oder API Key', + hinweis: 'Leer lassen, um den mitgelieferten zu behalten.', + }, + { + name: 'sprache', beschriftung: 'Sprache', wert: stand.language, + hinweis: 'Etwa de-DE. Bestimmt auch, welche Altersfreigabe gilt.', + }, + ], + bestaetigen: 'Speichern', + tun: async wert => { + await metadatenSetzen({ + apiKey: wert('schluessel') || undefined, + language: wert('sprache') || undefined, + }); + location.reload(); + }, + }))); - const reihe = document.createElement('div'); - reihe.className = 'knopfreihe'; - reihe.append(schluessel, sprache, los); - - los.onclick = async () => { - block.querySelector('.error')?.remove(); - los.disabled = true; - try { - await metadatenSetzen({ - apiKey: schluessel.value.trim() || undefined, - language: sprache.value.trim() || undefined, - }); - schluessel.value = ''; - auskunft.textContent = 'Gespeichert. Der nächste Scan benutzt die neuen Angaben.'; - } catch (fehler) { block.appendChild(fehlerzeile((fehler as Error).message)); } - los.disabled = false; - }; - - block.append(auskunft, reihe); + liste.appendChild(zeile('Fassung', auskunft.version, null)); } catch (fehler) { - block.appendChild(fehlerzeile((fehler as Error).message)); + liste.innerHTML = ''; + liste.appendChild(fehlerzeile((fehler as Error).message)); } })(); + function zeile( + name: string, wert: string, hinweis: string | null, bearbeiten?: () => void, + ): HTMLElement { + const d = document.createElement('div'); + d.className = 'einstellung'; + + const oben = document.createElement('div'); + oben.className = 'knopfreihe'; + const text = document.createElement('div'); + text.className = 'zeile'; + text.style.flex = '1'; + text.innerHTML = `${esc(name)}${esc(wert)}`; + oben.appendChild(text); + + if (bearbeiten) { + const b = knopf('Ändern', 'btn'); + b.prepend(icon('stift')); + b.onclick = bearbeiten; + oben.appendChild(b); + } + d.appendChild(oben); + + if (hinweis) { + const h = document.createElement('div'); + h.className = 'hinweis'; + h.textContent = hinweis; + d.appendChild(h); + } + return d; + } + + liste.innerHTML = '
'; return bereich; } diff --git a/web/app/src/views/detail.ts b/web/app/src/views/detail.ts index 272055d..9667ff4 100644 --- a/web/app/src/views/detail.ts +++ b/web/app/src/views/detail.ts @@ -540,7 +540,7 @@ function spieler(adresse: string, titel: string, ab: number, danach: () => void) // viermal je Sekunde; daraus Anfragen zu machen hiesse, den Server mit // Meldungen zu bewerfen, die sich um 250 ms unterscheiden. let zuletzt = 0; - const melden = (erzwingen = false) => { + const melden = (erzwingen = false, beendet = false) => { const jetzt = Date.now(); if (!erzwingen && jetzt - zuletzt < MELDEABSTAND) return; zuletzt = jetzt; @@ -548,18 +548,21 @@ function spieler(adresse: string, titel: string, ab: number, danach: () => void) // Eine verlorene Meldung kostet ein paar Sekunden Fortschritt. Dafür // mitten im Film eine Fehlermeldung zu zeigen wäre die schlechtere // Hälfte des Tauschs. - void fortschritt(titel, video.currentTime, laenge).catch(() => {}); + void fortschritt(titel, video.currentTime, laenge, beendet).catch(() => {}); }; video.ontimeupdate = () => melden(); video.onpause = () => melden(true); - video.onended = () => melden(true); + video.onended = () => melden(true, true); const schliessen = knopf('Schliessen', 'btn'); schliessen.style.cssText = 'position:absolute;top:16px;right:16px;z-index:2'; schliessen.onclick = () => { video.pause(); - melden(true); + // **Beendet melden, nicht nur den Stand.** Sonst steht der Film im + // Dashboard noch eine Minute als „läuft", und wer gerade geschlossen + // hat, sieht sich selbst beim Schauen zu. + melden(true, true); schicht.remove(); danach(); }; diff --git a/web/app/src/views/library.ts b/web/app/src/views/library.ts index 298ea91..aa2758b 100644 --- a/web/app/src/views/library.ts +++ b/web/app/src/views/library.ts @@ -1,5 +1,6 @@ import { inhalt, genresVon, empfehlungen as holeEmpfehlungen, type Bibliothek, type Ordnung } from '../api'; import { leer, fehlerzeile } from '../util'; +import { icons } from '../icons'; import { kachel, alsKachel, reihe } from '../card'; type Art = 'filme' | 'serien'; @@ -71,6 +72,8 @@ export function mediathek( let genre = ''; let tippt: number | undefined; + let gesehen: '' | 'offen' | 'gesehen' = ''; + const zeile = document.createElement('div'); zeile.className = 'filterzeile'; @@ -78,10 +81,42 @@ export function mediathek( anzahl.className = 'anzahl'; anzahl.textContent = '…'; - const genreWahl = document.createElement('select'); - genreWahl.innerHTML = ''; - genreWahl.onchange = () => { genre = genreWahl.value; void laden(); }; + /** Ein Filter als Pille — ohne Rahmen, gesetzt wird er farbig. */ + function pille( + vorgabe: string, + werte: [string, string][], + gewaehlt: () => string, + setzen: (wert: string) => void, + ): { huelle: HTMLElement; wahl: HTMLSelectElement } { + const huelle = document.createElement('div'); + huelle.className = 'pille'; + const wahl = document.createElement('select'); + [[ '', vorgabe ] as [string, string], ...werte].forEach(([w, t]) => { + const o = document.createElement('option'); + o.value = w; o.textContent = t; + wahl.appendChild(o); + }); + wahl.onchange = () => { + setzen(wahl.value); + huelle.classList.toggle('gesetzt', wahl.value !== ''); + aufraeumen(); + void laden(); + }; + huelle.classList.toggle('gesetzt', gewaehlt() !== ''); + huelle.appendChild(wahl); + return { huelle, wahl }; + } + const genrePille = pille('Alle Genres', [], () => genre, w => { genre = w; }); + + const standPille = pille('Alle', [ + ['offen', 'Noch nicht gesehen'], + ['gesehen', 'Gesehen'], + ], () => gesehen, w => { gesehen = w as typeof gesehen; }); + + // Die Sortierung ist kein Filter: sie ist immer gesetzt und bleibt grau. + const sortierHuelle = document.createElement('div'); + sortierHuelle.className = 'pille'; const sortierung = document.createElement('select'); ([ ['title', 'Nach Titel'], @@ -93,9 +128,30 @@ export function mediathek( sortierung.appendChild(o); }); sortierung.onchange = () => { ordnung = sortierung.value as Ordnung; void laden(); }; + sortierHuelle.appendChild(sortierung); + // Erscheint nur, wenn es etwas zurückzusetzen gibt. + const zuruecksetzen = document.createElement('button'); + zuruecksetzen.className = 'zuruecksetzen'; + zuruecksetzen.textContent = 'Filter zurücksetzen'; + zuruecksetzen.hidden = true; + zuruecksetzen.onclick = () => { + genre = ''; gesehen = ''; + genrePille.wahl.value = ''; standPille.wahl.value = ''; + genrePille.huelle.classList.remove('gesetzt'); + standPille.huelle.classList.remove('gesetzt'); + aufraeumen(); + void laden(); + }; + + function aufraeumen(): void { + zuruecksetzen.hidden = genre === '' && gesehen === ''; + } + + const suchhuelle = document.createElement('div'); + suchhuelle.className = 'suchhuelle'; + suchhuelle.innerHTML = icons.suche; const suchfeld = document.createElement('input'); - suchfeld.className = 'suche'; suchfeld.type = 'search'; suchfeld.placeholder = `In ${bibliothek.name} suchen`; suchfeld.oninput = () => { @@ -104,8 +160,10 @@ export function mediathek( window.clearTimeout(tippt); tippt = window.setTimeout(() => { suche = suchfeld.value.trim(); void laden(); }, 250); }; + suchhuelle.appendChild(suchfeld); - zeile.append(genreWahl, sortierung, anzahl, suchfeld); + zeile.append(genrePille.huelle, standPille.huelle, sortierHuelle, + anzahl, zuruecksetzen, suchhuelle); const gitter = document.createElement('div'); gitter.className = 'raster'; @@ -117,7 +175,7 @@ export function mediathek( namen.forEach(n => { const o = document.createElement('option'); o.value = n; o.textContent = n; - genreWahl.appendChild(o); + genrePille.wahl.appendChild(o); }); }).catch(() => {}); @@ -127,16 +185,30 @@ export function mediathek( gitter.innerHTML = '
'; try { const seite = await inhalt(bibliothek, { search: suche, sort: ordnung, genre, take: 200 }); - anzahl.textContent = String(seite.total); + + // **Gesehen/offen siebt hier, nicht am Server.** Er kann es noch + // nicht; es jetzt wegzulassen hiesse, einen Filter anzubieten, den + // es nicht gibt. Der Preis steht in der Zahl daneben: sie zählt, + // was übrig bleibt, nicht was der Server gefunden hat. + const uebrig = gesehen === '' ? seite.items + : seite.items.filter(e => { + const ist = 'state' in e ? e.state?.watched === true + : 'unwatchedCount' in e ? e.unwatchedCount === 0 : false; + return gesehen === 'gesehen' ? ist : !ist; + }); + + anzahl.textContent = gesehen === '' + ? String(seite.total) + : `${uebrig.length} von ${seite.total}`; gitter.innerHTML = ''; - if (seite.items.length === 0) { - gitter.appendChild(leer(suche || genre + if (uebrig.length === 0) { + gitter.appendChild(leer(suche || genre || gesehen ? 'Nichts gefunden.' : `„${bibliothek.name}" ist leer. Starte unter Verwaltung einen Scan.`)); return; } - seite.items.forEach(e => gitter.appendChild(kachel(alsKachel(e), oeffnen))); + uebrig.forEach(e => gitter.appendChild(kachel(alsKachel(e), oeffnen))); } catch (fehler) { gitter.innerHTML = ''; gitter.appendChild(fehlerzeile((fehler as Error).message));