Dialoge statt Blöcke, bessere Filter, ein Dashboard — und eine NAS-Anleitung
**Was etwas anlegt oder ändert, steht jetzt in einem Dialog.** Das Formular zum Anlegen einer Mediathek nahm als Block in der Seite Platz für etwas weg, das man einmal im Monat braucht — und sah aus wie eine Eingabe, die noch aussteht. Dasselbe für Benutzer, Umbenennen, den Servernamen und die Metadaten-Einstellungen. Gebaut mit dem `<dialog>` des Browsers, nicht mit einem nachgebauten: Escape schliesst, der Hintergrund wird unbedienbar, der Tastaturfokus bleibt gefangen, Vorlesesoftware kündigt ihn als Dialog an. Jede Zeile, die das selbst baut, ist eine Zeile, die es schlechter kann. Scheitert das Speichern, bleibt der Dialog offen — ihn zu schliessen hiesse, die Eingabe wegzuwerfen und die Meldung dorthin zu schreiben, wo gerade niemand hinsieht. **Die Filter sehen nicht mehr nach Eingabe aus.** Bei Plex stehen dort Wörter mit einem kleinen Pfeil; ein umrandetes Auswahlfeld sagt „hier fehlt noch etwas", und das stimmt nicht. Gesetzte Filter werden farbig, daneben steht die Anzahl, und „Filter zurücksetzen" erscheint nur, wenn es etwas zurückzusetzen gibt. Dazu neu: gesehen / noch nicht gesehen. Das siebt vorerst in der Oberfläche, nicht am Server — er kann es noch nicht. Der Preis steht in der Zahl daneben: sie zeigt dann „3 von 7". --- **„Wird abgespielt" — wer gerade was schaut.** Abgeleitet aus den Fortschrittsmeldungen, nicht aus einer eigenen Anmeldung am Spieler: jeder Client meldet beim Abspielen ohnehin alle zehn 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 nach einer Minute von selbst ab. Im Arbeitsspeicher, nicht in der Datenbank: „wer schaut gerade was" ist eine Frage über den Augenblick. Nach einem Neustart lautet die Antwort „niemand", und das zu speichern hiesse, Wiedergaben anzuzeigen, die es nicht gibt. Zwei Geräte desselben Menschen sind zwei Zeilen — derselbe Mensch kann auf dem Fernseher und auf dem Telefon gleichzeitig etwas laufen lassen. Geschlüsselt wird deshalb über das Gerätetoken, nicht über den Benutzer und nicht über den Gerätenamen: zwei iPhones heissen beide „iPhone". **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. Der Spieler meldet beim Schliessen ausdrücklich „beendet". Ohne das stünde ein geschlossener Film noch eine Minute als „läuft" — und wer gerade den Spieler zugemacht hat, sähe sich selbst beim Schauen zu. --- **`docs/NAS-EINRICHTEN.md`** beantwortet die Frage, die offen war: **es ist ein einziges Image.** Die Weboberfläche wird beim Bauen hineingelegt und vom Server selbst ausgeliefert — ein Container, drei Volumes, kein Proxy, keine Datenbank daneben. Drei Wege aufs NAS: über die Container-Registry deines Gitea (die läuft — nachgeprüft), als Datei über `docker save`, oder dort bauen. Dazu der Schritt, den man am leichtesten überspringt und am teuersten bezahlt: die Architektur. Ein Mac baut ohne Angabe arm64, ein Intel-NAS braucht amd64, und `exec format error` sagt das nicht deutlich. Eine Zahl darin war geraten und ist korrigiert: `docker save | gzip` ergibt nicht 90 MB, sondern 230 — die Ebenen eines Images sind bereits gepackt. 378 Tests grün, sechs davon neu. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a7ec3a73ba
commit
f2b5ee633b
@@ -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:<fassung> 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://<nas>: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://<nas>:8096/health
|
||||
```
|
||||
@@ -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";
|
||||
|
||||
/// <summary>Derselbe Zugang, nur für den Browser.</summary>
|
||||
///
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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.
|
||||
/// </remarks>
|
||||
public sealed record ProgressRequest(double PositionSeconds, double? RuntimeSeconds);
|
||||
public sealed record ProgressRequest(
|
||||
double PositionSeconds, double? RuntimeSeconds,
|
||||
/// <summary>Die letzte Meldung einer Wiedergabe.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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);
|
||||
|
||||
/// <summary>Eine laufende Wiedergabe — für das Dashboard.</summary>
|
||||
public sealed record SessionResponse(
|
||||
string UserName, string DeviceName,
|
||||
Guid ItemId, string Title, string? SubTitle, string? PosterImage,
|
||||
double PositionSeconds, double? RuntimeSeconds);
|
||||
|
||||
/// <summary>Was der Server gerade tut.</summary>
|
||||
public sealed record ServerStatusResponse(
|
||||
int Playing, int Packaging, int Scanning, double Uptime);
|
||||
|
||||
@@ -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);
|
||||
|
||||
/// <summary>Welches Gerät das ist — die Kennung seines Tokens.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// 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".
|
||||
/// </remarks>
|
||||
private static string Geraet(ClaimsPrincipal wer) =>
|
||||
wer.FindFirstValue(TokenAuthenticationHandler.DeviceTokenIdClaim) ?? "unbekannt";
|
||||
|
||||
/// <summary>Trägt die laufende Wiedergabe ins Dashboard ein.</summary>
|
||||
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;
|
||||
|
||||
|
||||
@@ -20,6 +20,9 @@ public static class SystemEndpoints
|
||||
/// <summary>Unter welchem Schlüssel der Servername liegt.</summary>
|
||||
public const string NameSchlüssel = "server.name";
|
||||
|
||||
/// <summary>Wann dieser Prozess angefangen hat.</summary>
|
||||
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();
|
||||
|
||||
@@ -49,6 +49,9 @@ public sealed class PackageCoordinator(
|
||||
|
||||
public PackageState? State(Guid fileId) => _zustand.GetValueOrDefault(fileId);
|
||||
|
||||
/// <summary>Wie viele Umwandlungen gerade laufen — für das Dashboard.</summary>
|
||||
public int LaufendeAnzahl => _zustand.Values.Count(z => z.Status == "running");
|
||||
|
||||
/// <summary>Wohin die umgepackte Datei kommt.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace Flaro.Api;
|
||||
|
||||
/// <summary>Was gerade läuft, je Gerät.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Im Arbeitsspeicher, nicht in der Datenbank.</b> „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".
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Abgeleitet aus den Fortschrittsmeldungen, nicht aus einer eigenen
|
||||
/// Anmeldung am Spieler.</b> 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class PlaybackSessions(TimeProvider clock)
|
||||
{
|
||||
/// <summary>Wie lange eine Wiedergabe ohne Meldung als laufend gilt.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public static readonly TimeSpan Lebensdauer = TimeSpan.FromMinutes(1);
|
||||
|
||||
private readonly ConcurrentDictionary<string, Wiedergabe> _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);
|
||||
|
||||
/// <summary>Hält fest, dass dieses Gerät gerade etwas abspielt.</summary>
|
||||
public void Melden(Wiedergabe wiedergabe) =>
|
||||
_laufend[wiedergabe.DeviceKey] = wiedergabe;
|
||||
|
||||
/// <summary>Nimmt eine Wiedergabe heraus — beim Beenden.</summary>
|
||||
public void Beenden(string deviceKey) => _laufend.TryRemove(deviceKey, out _);
|
||||
|
||||
/// <summary>Was gerade läuft. Abgelaufenes fällt dabei heraus.</summary>
|
||||
public IReadOnlyList<Wiedergabe> 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();
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,9 @@ builder.Services.AddScoped<MetadataEnricher>();
|
||||
builder.Services.AddScoped<MatchApplier>();
|
||||
builder.Services.AddSingleton<ScanCoordinator>();
|
||||
builder.Services.AddSingleton<PackageCoordinator>();
|
||||
// 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<PlaybackSessions>();
|
||||
builder.Services.AddProblemDetails();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
@@ -40,6 +40,10 @@ public sealed class ScanCoordinator(
|
||||
|
||||
public ScanState? State(Guid libraryId) => _zustand.GetValueOrDefault(libraryId);
|
||||
|
||||
/// <summary>Wie viele Läufe gerade unterwegs sind — für das Dashboard.</summary>
|
||||
public int LaufendeAnzahl =>
|
||||
_zustand.Values.Count(z => z.Status is "running" or "enriching");
|
||||
|
||||
public bool IsRunning(Guid libraryId) => State(libraryId)?.Status == "running";
|
||||
|
||||
/// <summary>Startet einen Lauf — oder sagt, dass schon einer läuft.</summary>
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Flaro.Api.Endpoints;
|
||||
|
||||
namespace Flaro.Api.Tests;
|
||||
|
||||
/// <summary>Wer gerade was schaut.</summary>
|
||||
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<HttpClient> 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<LibraryResponse>())!;
|
||||
|
||||
(await client.PostAsync($"/api/v1/libraries/{bibliothek.Id}/scan", null)).EnsureSuccessStatusCode();
|
||||
for (var versuch = 0; versuch < 100; versuch++)
|
||||
{
|
||||
var stand = await client.GetFromJsonAsync<ScanStatus>($"/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<Guid> ErsterFilm(HttpClient client) =>
|
||||
(await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies"))!.Items[0].Id;
|
||||
|
||||
/// <summary>Die Fortschrittsmeldung ist zugleich das Lebenszeichen.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Kein zweites Verfahren, das ein Client vergessen könnte: er meldet
|
||||
/// beim Abspielen ohnehin alle paar Sekunden, wo er steht.
|
||||
/// </remarks>
|
||||
[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<List<SessionResponse>>("/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));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Beendet heisst sofort weg, nicht erst nach einer Minute.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[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<List<SessionResponse>>("/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<Page<MovieResponse>>("/api/v1/movies");
|
||||
Assert.That(seite!.Items[0].State!.PositionSeconds, Is.EqualTo(900));
|
||||
}
|
||||
|
||||
/// <summary>Eine Folge nennt Serie und Nummer, nicht nur den Folgentitel.</summary>
|
||||
[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<Page<SeriesResponse>>("/api/v1/series");
|
||||
var folgen = await client.GetFromJsonAsync<Page<EpisodeResponse>>(
|
||||
$"/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<List<SessionResponse>>("/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"));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Wer wann was schaut, geht nicht jeden an.</summary>
|
||||
[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));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Zwei Geräte desselben Menschen sind zwei Wiedergaben.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Derselbe Mensch kann auf dem Fernseher und auf dem Telefon
|
||||
/// gleichzeitig etwas laufen lassen; beides gehört ins Dashboard.
|
||||
/// </remarks>
|
||||
[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<List<SessionResponse>>("/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<ServerStatusResponse>("/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));
|
||||
});
|
||||
}
|
||||
}
|
||||
+27
-2
@@ -318,10 +318,19 @@ export const naechsteFolgen = (take = 20) =>
|
||||
anfrage<Seite<Angefangen>>(`/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<Stand>(`/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<void>(`/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<Wiedergabe[]>('/system/sessions');
|
||||
|
||||
export const serverzustand = () => anfrage<Serverzustand>('/system/status');
|
||||
|
||||
export const servernameSetzen = (name: string) =>
|
||||
anfrage<void>('/system/name', { method: 'PUT', body: JSON.stringify({ name }) });
|
||||
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import { icons } from './icons';
|
||||
import { knopf } from './util';
|
||||
|
||||
/* Dialoge über dem Inhalt statt Blöcken darin.
|
||||
*
|
||||
* **Mit dem `<dialog>` des Browsers, nicht mit einem nachgebauten.** Der
|
||||
* kann, was ein `<div>` 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>;
|
||||
}): 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<string, HTMLInputElement | HTMLSelectElement>();
|
||||
|
||||
(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<string, string>();
|
||||
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<HTMLElement>('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>;
|
||||
}): void {
|
||||
dialog({
|
||||
titel: optionen.titel,
|
||||
text: optionen.text,
|
||||
bestaetigen: optionen.bestaetigen,
|
||||
gefaehrlich: true,
|
||||
tun: () => optionen.tun(),
|
||||
});
|
||||
}
|
||||
@@ -34,6 +34,7 @@ export const icons = {
|
||||
+ '<circle cx="12" cy="18.5" r="1.3" fill="currentColor" stroke="none"/>'),
|
||||
abmelden: rahmen('<path d="M15 4h3a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-3"/>'
|
||||
+ '<path d="M10 16.5 14.5 12 10 7.5"/><path d="M14.5 12H4"/>'),
|
||||
schliessen: rahmen('<path d="M6 6l12 12M18 6 6 18"/>'),
|
||||
stern: rahmen('<path d="m12 3.6 2.6 5.3 5.9.9-4.3 4.1 1 5.8-5.2-2.7-5.2 2.7 1-5.8L3.5 9.8l5.9-.9z"/>'),
|
||||
} as const;
|
||||
|
||||
|
||||
+100
-9
@@ -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 `<dialog>` 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;
|
||||
|
||||
+319
-167
@@ -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 = '<div class="spinner"></div>';
|
||||
|
||||
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<void> {
|
||||
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 = `<strong>${esc(w.title)}</strong>`
|
||||
+ (w.subTitle ? `<span class="winzig">${esc(w.subTitle)}</span>` : '')
|
||||
+ `<span class="winzig">${esc(w.userName)} · ${esc(w.deviceName)} · ${esc(wie)}</span>`;
|
||||
|
||||
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<void> {
|
||||
@@ -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 = `<strong>${esc(b.name)}</strong>`
|
||||
+ `<span class="winzig">${esc(b.path)}${esc(anzahl)}</span>`;
|
||||
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<void> {
|
||||
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 = '<option value="Movies">Filme</option><option value="Shows">Serien</option>';
|
||||
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<void> {
|
||||
@@ -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 = `<strong>${esc(name)}</strong><span class="winzig">${esc(wert)}</span>`;
|
||||
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 = '<div class="spinner"></div>';
|
||||
return bereich;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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 = '<option value="">Alle Genres</option>';
|
||||
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 = '<div class="spinner"></div>';
|
||||
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));
|
||||
|
||||
Reference in New Issue
Block a user