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:
Scarriffle
2026-10-04 08:03:07 +02:00
co-authored by Claude Opus 5
parent a7ec3a73ba
commit f2b5ee633b
17 changed files with 1394 additions and 194 deletions
@@ -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);
+21 -1
View File
@@ -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>
+69
View File
@@ -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();
}
}
+3
View File
@@ -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();
+4
View File
@@ -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>