**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>
205 lines
8.1 KiB
C#
205 lines
8.1 KiB
C#
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));
|
|
});
|
|
}
|
|
}
|