diff --git a/Tools/demo-medien.sh b/Tools/demo-medien.sh index c848514..87cb1cc 100755 --- a/Tools/demo-medien.sh +++ b/Tools/demo-medien.sh @@ -38,6 +38,16 @@ mach "$ZIEL/Serien/Dark/Staffel 1/Dark - S01E02.mkv" mkv mach "$ZIEL/Serien/Dark/Staffel 2/Dark - S02E01.mkv" mkv mach "$ZIEL/Serien/Notruf Hafenkante/Staffel 1/Notruf Hafenkante - S01E01.mkv" mkv zwei +# **Eine zweite Filmbibliothek, und das ist Absicht.** Mit nur „Filme" und +# „Serien" lässt sich nicht prüfen, ob mehrere Mediatheken wirklich getrennt +# bleiben — zwei Bestände derselben Art sind der Fall, in dem es auffällt. +echo "Dokumentationen:" +mach "$ZIEL/Dokus/Nomaden der Lüfte (2001)/Nomaden der Lüfte (2001).mkv" mkv +mach "$ZIEL/Dokus/Unsere Erde (2007)/Unsere Erde (2007).mp4" mp4 + +echo "Konzerte:" +mach "$ZIEL/Konzerte/Stop Making Sense (1984)/Stop Making Sense (1984).mkv" mkv zwei + chmod -R a+rwX "$ZIEL" echo echo "Fertig: $ZIEL" diff --git a/server/src/Flaro.Api/Endpoints/Contracts.cs b/server/src/Flaro.Api/Endpoints/Contracts.cs index 01cad0a..3827d37 100644 --- a/server/src/Flaro.Api/Endpoints/Contracts.cs +++ b/server/src/Flaro.Api/Endpoints/Contracts.cs @@ -29,7 +29,17 @@ public sealed record CodeState(string State, LoginResponse? Login); public sealed record CreateUserRequest(string UserName, string Password, bool IsAdministrator); -public sealed record LibraryResponse(Guid Id, string Name, string Kind, string Path); +/// Eine Bibliothek, wie die Oberfläche sie zeigt. +/// +/// +/// ItemCount ist null, wenn er nicht mitgezählt wurde — etwa +/// direkt nach dem Anlegen oder Umbenennen. Nicht 0: „keine Titel" und +/// „nicht gezählt" sind verschiedene Auskünfte, und eine frisch angelegte +/// Bibliothek als leer auszugeben wäre die falsche. +/// +public sealed record LibraryResponse(Guid Id, string Name, string Kind, string Path, int? ItemCount); + +public sealed record RenameLibraryRequest(string Name); public sealed record CreateLibraryRequest(string Name, string Kind, string Path); diff --git a/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs b/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs index a8a7f7d..d42df36 100644 --- a/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs @@ -16,6 +16,24 @@ public static class LibraryContentEndpoints /// private const int HöchstensJeSeite = 200; + /// Wonach eine Liste geordnet wird. + /// + /// + /// Als Wort, nicht als Spaltenname. „added" sagt, was gemeint ist; + /// `AddedAt desc` verriete den Aufbau der Datenbank und wäre beim + /// nächsten Umbau falsch. Unbekanntes fällt auf den Titel zurück, statt + /// die Anfrage abzulehnen — eine Liste in falscher Reihenfolge ist + /// besser als keine. + /// + private enum Reihenfolge { Title, Added, Year } + + private static Reihenfolge Ordnung(string? wunsch) => wunsch?.ToLowerInvariant() switch + { + "added" => Reihenfolge.Added, + "year" => Reihenfolge.Year, + _ => Reihenfolge.Title, + }; + public static void MapLibraryContent(this IEndpointRouteBuilder app) { MapScan(app); @@ -126,16 +144,26 @@ public static class LibraryContentEndpoints private static void MapBrowse(IEndpointRouteBuilder app) { app.MapGet("/api/v1/movies", async ( - ClaimsPrincipal wer, FlaroContext db, Guid? library, string? search, int skip = 0, int take = 50) => + ClaimsPrincipal wer, FlaroContext db, + Guid? library, string? search, string? sort, int skip = 0, int take = 50) => { var abfrage = db.Movies.AsNoTracking(); if (library is { } kennung) abfrage = abfrage.Where(m => m.LibraryId == kennung); if (!string.IsNullOrWhiteSpace(search)) abfrage = abfrage.Where(m => EF.Functions.Like(m.Title, $"%{search.Trim()}%")); + // Der Titel immer als letzter Vergleich: zwei Filme aus demselben + // Jahr hätten sonst keine feste Reihenfolge, und eine Seite 2 + // zeigte dann denselben Film noch einmal oder gar nicht. + abfrage = Ordnung(sort) switch + { + Reihenfolge.Added => abfrage.OrderByDescending(m => m.AddedAt).ThenBy(m => m.SortTitleValue), + Reihenfolge.Year => abfrage.OrderByDescending(m => m.Year).ThenBy(m => m.SortTitleValue), + _ => abfrage.OrderBy(m => m.SortTitleValue).ThenBy(m => m.Year), + }; + var gesamt = await abfrage.CountAsync(); var kennungen = await abfrage - .OrderBy(m => m.SortTitleValue).ThenBy(m => m.Year) .Skip(Math.Max(0, skip)).Take(Math.Clamp(take, 1, HöchstensJeSeite)) .Select(m => new { m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage }) .ToListAsync(); @@ -161,16 +189,23 @@ public static class LibraryContentEndpoints }).RequireAuthorization(); app.MapGet("/api/v1/series", async ( - ClaimsPrincipal wer, FlaroContext db, Guid? library, string? search, int skip = 0, int take = 50) => + ClaimsPrincipal wer, FlaroContext db, + Guid? library, string? search, string? sort, int skip = 0, int take = 50) => { var abfrage = db.Series.AsNoTracking(); if (library is { } kennung) abfrage = abfrage.Where(s => s.LibraryId == kennung); if (!string.IsNullOrWhiteSpace(search)) abfrage = abfrage.Where(s => EF.Functions.Like(s.Title, $"%{search.Trim()}%")); + abfrage = Ordnung(sort) switch + { + Reihenfolge.Added => abfrage.OrderByDescending(s => s.AddedAt).ThenBy(s => s.SortTitleValue), + Reihenfolge.Year => abfrage.OrderByDescending(s => s.Year).ThenBy(s => s.SortTitleValue), + _ => abfrage.OrderBy(s => s.SortTitleValue).ThenBy(s => s.Year), + }; + var gesamt = await abfrage.CountAsync(); var serien = await abfrage - .OrderBy(s => s.SortTitleValue) .Skip(Math.Max(0, skip)).Take(Math.Clamp(take, 1, HöchstensJeSeite)) .Select(s => new { s.Id, s.Title, s.Year, s.Overview, s.PosterImage, s.BackdropImage }) .ToListAsync(); diff --git a/server/src/Flaro.Api/Endpoints/ManagementEndpoints.cs b/server/src/Flaro.Api/Endpoints/ManagementEndpoints.cs index eba52d5..ed01063 100644 --- a/server/src/Flaro.Api/Endpoints/ManagementEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/ManagementEndpoints.cs @@ -45,12 +45,32 @@ public static class ManagementEndpoints var bibliotheken = app.MapGroup("/api/v1/libraries"); // Lesen darf jeder Angemeldete — ohne die Liste zeigt die App nichts an. + // + // **Mit Zählwert.** Die Seitenleiste führt jede Bibliothek einzeln + // auf, und „Dokumentationen · 0" sagt mehr als ein Eintrag, hinter + // dem sich nach dem Klick nichts findet — da ist der Unterschied + // zwischen „noch nicht gescannt" und „falscher Pfad" wenigstens + // sichtbar. Zwei Abfragen für alle zusammen, nicht eine je Zeile. bibliotheken.MapGet("/", async (FlaroContext db) => - Results.Ok(await db.Libraries - .OrderBy(l => l.Name) - .Select(l => new LibraryResponse(l.Id, l.Name, l.Kind.ToString(), l.Path)) - .ToListAsync())) - .RequireAuthorization(); + { + var liste = await db.Libraries.AsNoTracking().OrderBy(l => l.Name).ToListAsync(); + + var filme = await db.Movies.AsNoTracking() + .GroupBy(m => m.LibraryId) + .Select(g => new { Id = g.Key, Anzahl = g.Count() }) + .ToDictionaryAsync(x => x.Id, x => x.Anzahl); + + var serien = await db.Series.AsNoTracking() + .GroupBy(x => x.LibraryId) + .Select(g => new { Id = g.Key, Anzahl = g.Count() }) + .ToDictionaryAsync(x => x.Id, x => x.Anzahl); + + return Results.Ok(liste.Select(l => new LibraryResponse( + l.Id, l.Name, l.Kind.ToString(), l.Path, + l.Kind == LibraryKind.Shows + ? serien.GetValueOrDefault(l.Id) + : filme.GetValueOrDefault(l.Id))).ToList()); + }).RequireAuthorization(); bibliotheken.MapPost("/", async (CreateLibraryRequest anfrage, FlaroContext db) => { @@ -74,7 +94,45 @@ public static class ManagementEndpoints db.Libraries.Add(neu); await db.SaveChangesAsync(); return Results.Created($"/api/v1/libraries/{neu.Id}", - new LibraryResponse(neu.Id, neu.Name, neu.Kind.ToString(), neu.Path)); + new LibraryResponse(neu.Id, neu.Name, neu.Kind.ToString(), neu.Path, 0)); + }).RequireAuthorization(FlaroPolicies.Administrator); + + // **Umbenennen, mehr nicht.** Die Art zu ändern hiesse, Filme in + // Serien zu verwandeln; den Pfad zu ändern hiesse, einen ganzen + // Bestand gegen einen anderen zu tauschen, während die Einträge + // stehenbleiben. Beides ist kein Bearbeiten, sondern eine neue + // Bibliothek — und die legt man an. + bibliotheken.MapPut("/{id:guid}", async ( + Guid id, RenameLibraryRequest anfrage, FlaroContext db) => + { + var name = anfrage.Name.Trim(); + if (name.Length == 0) + return Results.ValidationProblem(new Dictionary + { + ["name"] = ["Ein Name darf nicht leer sein."], + }); + + var vorhanden = await db.Libraries.FindAsync(id); + if (vorhanden is null) return Results.NotFound(); + + vorhanden.Name = name; + await db.SaveChangesAsync(); + return Results.Ok(new LibraryResponse( + vorhanden.Id, vorhanden.Name, vorhanden.Kind.ToString(), vorhanden.Path, null)); + }).RequireAuthorization(FlaroPolicies.Administrator); + + // **Die Mediendateien bleiben liegen.** Entfernt wird, was Flaro über + // sie weiss: Titel, Zuordnungen, Fortschritt. Ein Server, der beim + // Aufräumen in der Oberfläche fremde Filme löscht, ist ein Server, + // den man nicht noch einmal benutzt. + bibliotheken.MapDelete("/{id:guid}", async (Guid id, FlaroContext db) => + { + var vorhanden = await db.Libraries.FindAsync(id); + if (vorhanden is null) return Results.NotFound(); + + db.Libraries.Remove(vorhanden); + await db.SaveChangesAsync(); + return Results.NoContent(); }).RequireAuthorization(FlaroPolicies.Administrator); } } diff --git a/server/src/Flaro.Api/Program.cs b/server/src/Flaro.Api/Program.cs index b26e26b..45d0c66 100644 --- a/server/src/Flaro.Api/Program.cs +++ b/server/src/Flaro.Api/Program.cs @@ -10,6 +10,7 @@ using Flaro.Metadata; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; +using Microsoft.Net.Http.Headers; var builder = WebApplication.CreateBuilder(args); @@ -124,7 +125,46 @@ var oberfläche = Path.Combine(app.Environment.ContentRootPath, "wwwroot"); if (Directory.Exists(oberfläche)) { app.UseDefaultFiles(); - app.UseStaticFiles(); + + // **Zwei Lebensdauern, und die Unterscheidung ist nötig.** + // + // Die Dateien unter `assets/` tragen eine Prüfsumme im Namen: ändert + // sich ihr Inhalt, ändert sich der Name. Sie dürfen deshalb für immer im + // Zwischenspeicher liegen — und sollen es auch, sonst lädt jeder Besuch + // dasselbe Skript erneut. + // + // `index.html` trägt keine. Sie nennt die Namen der Skripte, und wer sie + // aus dem Zwischenspeicher nimmt, bekommt nach einem Server-Update + // weiter die alte App — nicht kaputt, sondern **veraltet**, und das + // fällt niemandem auf. Genau das ist hier passiert: der Browser lud + // `index-C0K3V8eg.js`, während der Server längst `index-DomHN4_b.js` + // auslieferte. + app.UseStaticFiles(new StaticFileOptions + { + OnPrepareResponse = zusammenhang => + { + var name = zusammenhang.File.Name; + var kopf = zusammenhang.Context.Response.GetTypedHeaders(); + + if (name.EndsWith(".html", StringComparison.OrdinalIgnoreCase)) + { + kopf.CacheControl = new CacheControlHeaderValue + { + NoCache = true, + MustRevalidate = true, + }; + } + else + { + kopf.CacheControl = new CacheControlHeaderValue + { + Public = true, + MaxAge = TimeSpan.FromDays(365), + Extensions = { new NameValueHeaderValue("immutable") }, + }; + } + }, + }); } app.UseAuthentication(); @@ -151,8 +191,18 @@ app.Map("/api/{*rest}", () => Results.NotFound()).AllowAnonymous(); // endet ein Neuladen auf /verwaltung in einem 404. if (Directory.Exists(oberfläche)) { - app.MapFallback(() => Results.File(Path.Combine(oberfläche, "index.html"), "text/html")) - .AllowAnonymous(); + // Auch hier `no-cache`: der Rückfall liefert dieselbe `index.html`, und + // sie über diesen Weg zwischenspeichern zu lassen hätte genau denselben + // Effekt wie oben. + app.MapFallback((HttpContext zusammenhang) => + { + zusammenhang.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue + { + NoCache = true, + MustRevalidate = true, + }; + return Results.File(Path.Combine(oberfläche, "index.html"), "text/html"); + }).AllowAnonymous(); } app.Run(); diff --git a/server/tests/Flaro.Api.Tests/MultipleLibraryTests.cs b/server/tests/Flaro.Api.Tests/MultipleLibraryTests.cs new file mode 100644 index 0000000..859b2e5 --- /dev/null +++ b/server/tests/Flaro.Api.Tests/MultipleLibraryTests.cs @@ -0,0 +1,247 @@ +using System.Net; +using System.Net.Http.Json; +using Flaro.Api.Endpoints; + +namespace Flaro.Api.Tests; + +/// Mehrere Mediatheken nebeneinander — wie bei Plex und Jellyfin. +/// +/// +/// Zwei Filmbibliotheken sind keine doppelte Filmbibliothek. „Filme" +/// und „Dokumentationen" liegen in verschiedenen Ordnern, gehören in der +/// Seitenleiste getrennt und dürfen sich in keiner Liste vermischen. Dass +/// das Modell es kann, hiess bisher nicht, dass es geprüft war. +/// +public class MultipleLibraryTests +{ + private readonly List _ordner = []; + + [TearDown] + public void Aufraeumen() + { + foreach (var o in _ordner) + { + try { Directory.Delete(o, recursive: true); } catch (IOException) { /* Kür */ } + } + _ordner.Clear(); + } + + /// Ein Ordner mit den genannten Dateien darin. + private string Bestand(params string[] dateien) + { + var wurzel = Directory.CreateDirectory( + Path.Combine(Path.GetTempPath(), "flaro-mehrere-" + Guid.CreateVersion7().ToString("N"))).FullName; + _ordner.Add(wurzel); + + foreach (var relativ in dateien) + { + var voll = Path.Combine(wurzel, relativ); + Directory.CreateDirectory(Path.GetDirectoryName(voll)!); + File.WriteAllBytes(voll, new byte[2048]); + } + return wurzel; + } + + private static async Task Anlegen( + HttpClient client, string name, string art, string pfad) + { + var antwort = await client.PostAsJsonAsync("/api/v1/libraries", + new CreateLibraryRequest(name, art, pfad)); + antwort.EnsureSuccessStatusCode(); + return (await antwort.Content.ReadFromJsonAsync())!; + } + + private static async Task Scannen(HttpClient client, Guid bibliothek) + { + (await client.PostAsync($"/api/v1/libraries/{bibliothek}/scan", null)).EnsureSuccessStatusCode(); + for (var versuch = 0; versuch < 100; versuch++) + { + var stand = await client.GetFromJsonAsync($"/api/v1/libraries/{bibliothek}/scan"); + if (stand!.State is "finished") return; + 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."); + } + + [Test] + public async Task Zwei_Filmbibliotheken_bleiben_getrennt() + { + var spielfilme = Bestand("Matrix (1999)/Matrix (1999).mkv", "Inception (2010)/Inception (2010).mkv"); + var dokus = Bestand("Winged Migration (2001)/Winged Migration (2001).mkv"); + + using var server = new FlaroServer(); + var client = server.AlsAngemeldeter((await server.SetUpAndSignIn()).Token); + + var a = await Anlegen(client, "Filme", "Movies", spielfilme); + var b = await Anlegen(client, "Dokumentationen", "Movies", dokus); + await Scannen(client, a.Id); + await Scannen(client, b.Id); + + var inA = await client.GetFromJsonAsync>($"/api/v1/movies?library={a.Id}"); + var inB = await client.GetFromJsonAsync>($"/api/v1/movies?library={b.Id}"); + var alle = await client.GetFromJsonAsync>("/api/v1/movies"); + + Assert.Multiple(() => + { + Assert.That(inA!.Total, Is.EqualTo(2)); + Assert.That(inB!.Total, Is.EqualTo(1)); + Assert.That(inB.Items[0].Title, Is.EqualTo("Winged Migration")); + // Ohne Einschränkung alle zusammen — für die Suche quer durch alles. + Assert.That(alle!.Total, Is.EqualTo(3)); + }); + } + + /// Die Seitenleiste braucht zu jeder Zeile eine Zahl. + [Test] + public async Task Die_Liste_nennt_je_Bibliothek_die_Anzahl() + { + var filme = Bestand("Matrix (1999)/Matrix (1999).mkv"); + var serien = Bestand("Dark/Season 01/Dark - S01E01 - Geheimnisse.mkv", + "Dark/Season 01/Dark - S01E02 - Luegen.mkv"); + var leer = Bestand(); + + using var server = new FlaroServer(); + var client = server.AlsAngemeldeter((await server.SetUpAndSignIn()).Token); + + var a = await Anlegen(client, "Filme", "Movies", filme); + var b = await Anlegen(client, "Serien", "Shows", serien); + await Anlegen(client, "Noch nichts drin", "Movies", leer); + await Scannen(client, a.Id); + await Scannen(client, b.Id); + + var liste = await client.GetFromJsonAsync>("/api/v1/libraries"); + var nachNamen = liste!.ToDictionary(l => l.Name); + + Assert.Multiple(() => + { + Assert.That(nachNamen["Filme"].ItemCount, Is.EqualTo(1)); + // **Serien, nicht Folgen.** Zwei Folgen einer Serie sind eine Zeile. + Assert.That(nachNamen["Serien"].ItemCount, Is.EqualTo(1)); + Assert.That(nachNamen["Noch nichts drin"].ItemCount, Is.Zero); + }); + } + + [Test] + public async Task Eine_Bibliothek_laesst_sich_umbenennen() + { + using var server = new FlaroServer(); + var client = server.AlsAngemeldeter((await server.SetUpAndSignIn()).Token); + var angelegt = await Anlegen(client, "Flime", "Movies", Bestand()); + + var antwort = await client.PutAsJsonAsync($"/api/v1/libraries/{angelegt.Id}", + new RenameLibraryRequest("Filme")); + var geaendert = await antwort.Content.ReadFromJsonAsync(); + + Assert.Multiple(() => + { + Assert.That(geaendert!.Name, Is.EqualTo("Filme")); + Assert.That(geaendert.Path, Is.EqualTo(angelegt.Path), "der Pfad bleibt, wo er war"); + }); + } + + [Test] + public async Task Ein_leerer_Name_wird_abgelehnt() + { + using var server = new FlaroServer(); + var client = server.AlsAngemeldeter((await server.SetUpAndSignIn()).Token); + var angelegt = await Anlegen(client, "Filme", "Movies", Bestand()); + + var antwort = await client.PutAsJsonAsync($"/api/v1/libraries/{angelegt.Id}", + new RenameLibraryRequest(" ")); + + Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + } + + /// Entfernt wird der Eintrag — nicht der Film auf der Platte. + /// + /// + /// Das ist die wichtigste Zusage dieses Servers. Ein Server, der + /// beim Aufräumen in der Oberfläche fremde Mediendateien löscht, ist + /// einer, den man nicht noch einmal benutzt. + /// + [Test] + public async Task Loeschen_nimmt_die_Eintraege_und_laesst_die_Dateien_liegen() + { + var ordner = Bestand("Matrix (1999)/Matrix (1999).mkv"); + var datei = Path.Combine(ordner, "Matrix (1999)", "Matrix (1999).mkv"); + + using var server = new FlaroServer(); + var client = server.AlsAngemeldeter((await server.SetUpAndSignIn()).Token); + var angelegt = await Anlegen(client, "Filme", "Movies", ordner); + await Scannen(client, angelegt.Id); + + Assert.That((await client.GetFromJsonAsync>("/api/v1/movies"))!.Total, + Is.EqualTo(1), "vorher ist er da"); + + var geloescht = await client.DeleteAsync($"/api/v1/libraries/{angelegt.Id}"); + + Assert.Multiple(() => + { + Assert.That(geloescht.StatusCode, Is.EqualTo(HttpStatusCode.NoContent)); + Assert.That(File.Exists(datei), Is.True, "die Datei liegt noch da"); + }); + + var danach = await client.GetFromJsonAsync>("/api/v1/movies"); + var liste = await client.GetFromJsonAsync>("/api/v1/libraries"); + + Assert.Multiple(() => + { + Assert.That(danach!.Total, Is.Zero); + Assert.That(liste, Is.Empty); + }); + } + + [Test] + public async Task Nur_Administratoren_duerfen_loeschen_und_umbenennen() + { + using var server = new FlaroServer(); + var admin = server.AlsAngemeldeter((await server.SetUpAndSignIn()).Token); + var angelegt = await Anlegen(admin, "Filme", "Movies", Bestand()); + + (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); + + var umbenannt = await gast.PutAsJsonAsync($"/api/v1/libraries/{angelegt.Id}", + new RenameLibraryRequest("Meins jetzt")); + var geloescht = await gast.DeleteAsync($"/api/v1/libraries/{angelegt.Id}"); + + Assert.Multiple(() => + { + Assert.That(umbenannt.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden)); + Assert.That(geloescht.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden)); + }); + } + + /// „Zuletzt hinzugefügt" braucht eine andere Reihenfolge als A–Z. + [Test] + public async Task Nach_Aufnahme_sortiert_steht_das_Neueste_oben() + { + var ordner = Bestand("Zzz Zuletzt (2024)/Zzz Zuletzt (2024).mkv"); + + using var server = new FlaroServer(); + var client = server.AlsAngemeldeter((await server.SetUpAndSignIn()).Token); + var angelegt = await Anlegen(client, "Filme", "Movies", ordner); + await Scannen(client, angelegt.Id); + + // Ein zweiter Film, nachweislich später aufgenommen. + var voll = Path.Combine(ordner, "Aaa Zuerst (1999)", "Aaa Zuerst (1999).mkv"); + Directory.CreateDirectory(Path.GetDirectoryName(voll)!); + await File.WriteAllBytesAsync(voll, new byte[2048]); + await Task.Delay(1100); + await Scannen(client, angelegt.Id); + + var alphabetisch = await client.GetFromJsonAsync>("/api/v1/movies"); + var nachAufnahme = await client.GetFromJsonAsync>("/api/v1/movies?sort=added"); + + Assert.Multiple(() => + { + Assert.That(alphabetisch!.Items.Select(m => m.Title), + Is.EqualTo(new[] { "Aaa Zuerst", "Zzz Zuletzt" })); + Assert.That(nachAufnahme!.Items[0].Title, Is.EqualTo("Aaa Zuerst"), + "zuletzt gefunden steht oben"); + }); + } +} diff --git a/server/tests/Flaro.Api.Tests/WebInterfaceTests.cs b/server/tests/Flaro.Api.Tests/WebInterfaceTests.cs index 1b8cf8e..83b40bc 100644 --- a/server/tests/Flaro.Api.Tests/WebInterfaceTests.cs +++ b/server/tests/Flaro.Api.Tests/WebInterfaceTests.cs @@ -140,6 +140,49 @@ public sealed class WebInterfaceTests } } + /// Die Seite darf nicht im Zwischenspeicher hängenbleiben. + /// + /// + /// Der Fehler, den der Browser zeigte. Die Dateien unter + /// assets/ tragen eine Prüfsumme im Namen; index.html nicht + /// — sie nennt nur deren Namen. Lag sie im Zwischenspeicher, bekam der + /// Browser nach einem Server-Update weiter die alte App: nicht kaputt, + /// sondern veraltet, und das fällt niemandem auf. Nachgemessen hat es + /// sich so: geladen war index-C0K3V8eg.js, ausgeliefert wurde + /// längst index-DomHN4_b.js. + /// + [Test] + public async Task Die_Seite_wird_nicht_zwischengespeichert_die_Bausteine_schon() + { + var wurzel = Path.Combine(Path.GetTempPath(), "flaro-www-" + Guid.CreateVersion7().ToString("N")); + Directory.CreateDirectory(Path.Combine(wurzel, "wwwroot", "assets")); + await File.WriteAllTextAsync(Path.Combine(wurzel, "wwwroot", "index.html"), "Flaro"); + await File.WriteAllTextAsync(Path.Combine(wurzel, "wwwroot", "assets", "index-abc123.js"), "export const da = true;"); + + try + { + using var server = new FlaroServer(inhaltswurzel: wurzel); + var client = server.OhneGedaechtnis(); + + var seite = await client.GetAsync("/"); + var unterseite = await client.GetAsync("/verwaltung"); + var skript = await client.GetAsync("/assets/index-abc123.js"); + + Assert.Multiple(() => + { + Assert.That(seite.Headers.CacheControl?.NoCache, Is.True); + Assert.That(unterseite.Headers.CacheControl?.NoCache, Is.True, + "der SPA-Rückfall liefert dieselbe Datei"); + Assert.That(skript.Headers.CacheControl?.MaxAge, Is.EqualTo(TimeSpan.FromDays(365))); + Assert.That(skript.Headers.CacheControl?.ToString(), Does.Contain("immutable")); + }); + } + finally + { + try { Directory.Delete(wurzel, recursive: true); } catch (IOException) { /* Aufräumen ist Kür */ } + } + } + /// Ein unbekannter Pfad unter /api ist ein Fehler, keine Seite. /// /// diff --git a/web/app/src/api.ts b/web/app/src/api.ts index fe92ea6..c64933f 100644 --- a/web/app/src/api.ts +++ b/web/app/src/api.ts @@ -45,7 +45,14 @@ export type Angefangen = { posterImage: string | null; backdropImage: string | null; state: Stand; }; -export type Bibliothek = { id: string; name: string; kind: string; path: string }; +export type Bibliothek = { + id: string; name: string; + /** „Movies" oder „Shows" — so nennt der Server es. */ + kind: 'Movies' | 'Shows'; + path: string; + /** `null` heisst „nicht gezählt", nicht „leer". */ + itemCount: number | null; +}; export type Seite = { items: T[]; total: number }; export type Lieferung = { method: 'directFile' | 'remux'; reasons: string[]; @@ -177,11 +184,25 @@ function geraetekennung(): string { /* ---------- Mediathek ---------- */ -export const filme = (q: { search?: string; skip?: number; take?: number } = {}) => - anfrage>(`/movies${abfrage(q)}`); +/** Wonach eine Liste geordnet wird — die Wörter des Servers. */ +export type Ordnung = 'title' | 'added' | 'year'; -export const serien = (q: { search?: string; skip?: number; take?: number } = {}) => - anfrage>(`/series${abfrage(q)}`); +type Suchfrage = { + library?: string; search?: string; sort?: Ordnung; skip?: number; take?: number; +}; + +export const filme = (q: Suchfrage = {}) => anfrage>(`/movies${abfrage(q)}`); + +export const serien = (q: Suchfrage = {}) => anfrage>(`/series${abfrage(q)}`); + +/** Holt aus einer Mediathek, was auch immer darin liegt. + * + * Die Unterscheidung Film/Serie steckt in der Mediathek, nicht im Aufrufer — + * wer eine Reihe zeichnet, will Kacheln und nicht zwei Codepfade. */ +export const inhalt = (b: Bibliothek, q: Omit = {}) => + b.kind === 'Shows' + ? serien({ ...q, library: b.id }) + : filme({ ...q, library: b.id }); export const folgen = (serie: string, staffel?: number) => anfrage>(`/series/${serie}/episodes${abfrage({ season: staffel })}`); @@ -226,6 +247,15 @@ export const downloadAdresse = (datei: string) => `/api/v1/files/${datei}/downlo export const bibliotheken = () => anfrage('/libraries'); +export const bibliothekUmbenennen = (id: string, name: string) => + anfrage(`/libraries/${id}`, { + method: 'PUT', body: JSON.stringify({ name }), + }); + +/** Entfernt den Eintrag — die Mediendateien bleiben liegen. */ +export const bibliothekLoeschen = (id: string) => + anfrage(`/libraries/${id}`, { method: 'DELETE' }); + export const bibliothekAnlegen = (name: string, art: string, pfad: string) => anfrage('/libraries', { method: 'POST', body: JSON.stringify({ name, kind: art, path: pfad }), diff --git a/web/app/src/main.ts b/web/app/src/main.ts index 557b55c..26bbdce 100644 --- a/web/app/src/main.ts +++ b/web/app/src/main.ts @@ -1,6 +1,7 @@ import './style.css'; import { - systemauskunft, angemeldet, istAdmin, abmelden, sitzung, type Hinweis, + systemauskunft, angemeldet, istAdmin, abmelden, sitzung, bibliotheken, + type Hinweis, type Bibliothek, } from './api'; import { einrichtung } from './views/setup'; import { anmeldung } from './views/login'; @@ -16,6 +17,8 @@ const leiste = document.getElementById('chrome')!; const inhalt = document.getElementById('content')!; let hinweise: Hinweis[] = []; +/** Die Mediatheken, einmal geholt — die Seitenleiste steht auf ihnen. */ +let mediatheken: Bibliothek[] = []; /* ---------- Start ---------- */ @@ -31,6 +34,12 @@ async function hochfahren(): Promise { anmeldeflaeche.classList.add('hidden'); app.classList.remove('hidden'); + + // **Vor dem ersten Zeichnen.** Die Seitenleiste führt jede Mediathek + // einzeln auf; ohne die Liste gäbe es nichts zu zeichnen und der Router + // könnte `#/m/{id}` nicht auflösen. + mediatheken = await bibliotheken(); + leisteBauen(); window.addEventListener('hashchange', zeichnen); zeichnen(); @@ -41,6 +50,8 @@ async function hochfahren(): Promise { ``; + anmeldeflaeche.classList.remove('hidden'); + app.classList.add('hidden'); } } @@ -51,6 +62,13 @@ function zeigeAnmeldung(karte: HTMLElement): void { app.classList.add('hidden'); } +/** Lädt die Mediathekenliste neu — nach dem Anlegen oder Löschen einer. */ +export async function mediathekenNeuLaden(): Promise { + mediatheken = await bibliotheken(); + leisteBauen(); + zeichnen(); +} + /* ---------- Seitenleiste ---------- */ function leisteBauen(): void { @@ -61,10 +79,15 @@ function leisteBauen(): void { marke.textContent = 'Flaro'; leiste.appendChild(marke); - gruppe('Mediathek'); eintrag('Start', '#/'); - eintrag('Filme', '#/filme'); - eintrag('Serien', '#/serien'); + + // **Jede Mediathek ihre eigene Zeile**, mit Namen und Zahl. Eine Zeile + // „Filme", hinter der zwei getrennt angelegte Bestände liegen, nähme die + // Entscheidung zurück, sie zu trennen. + if (mediatheken.length) { + gruppe('Mediatheken'); + mediatheken.forEach(b => eintrag(b.name, `#/m/${b.id}`, b.itemCount)); + } if (istAdmin()) { gruppe('Server'); eintrag('Verwaltung', '#/verwaltung'); } @@ -82,10 +105,12 @@ function leisteBauen(): void { d.textContent = text; leiste.appendChild(d); } - function eintrag(text: string, ziel: string): void { + + function eintrag(text: string, ziel: string, anzahl?: number | null): void { const b = document.createElement('button'); - b.textContent = text; b.dataset.ziel = ziel; + b.innerHTML = `${esc(text)}` + + (anzahl != null ? `${anzahl}` : ''); b.onclick = () => { location.hash = ziel; }; leiste.appendChild(b); } @@ -98,27 +123,32 @@ function zeichnen(): void { const teile = weg.split('/').filter(Boolean); leiste.querySelectorAll('button[data-ziel]').forEach(b => { - const ziel = (b as HTMLElement).dataset.ziel!.replace(/^#\/?/, ''); - b.classList.toggle('check', ziel === (teile[0] ?? '')); + const ziel = (b as HTMLElement).dataset.ziel!; + b.classList.toggle('check', ziel === `#/${teile.join('/')}`); }); inhalt.innerHTML = ''; - inhalt.scrollTo?.(0, 0); window.scrollTo(0, 0); const oeffnen = (art: 'filme' | 'serien', id: string) => { location.hash = `#/${art}/${id}`; }; + const alleZeigen = (b: Bibliothek) => { location.hash = `#/m/${b.id}`; }; const art = teile[0] === 'filme' || teile[0] === 'serien' ? teile[0] : null; const kennung = teile[1]; if (art && kennung) { inhalt.appendChild(detailseite(art, kennung, () => history.back())); - } else if (art) { - inhalt.appendChild(mediathek(art, oeffnen)); + } else if (teile[0] === 'm' && kennung) { + const b = mediatheken.find(x => x.id === kennung); + // Eine Mediathek, die es nicht mehr gibt — etwa, weil sie in einem + // anderen Fenster gelöscht wurde. Zurück auf Start ist hier richtiger + // als eine Fehlermeldung über eine Kennung, die niemand gelesen hat. + if (b) inhalt.appendChild(mediathek(b, oeffnen)); + else location.hash = '#/'; } else if (teile[0] === 'verwaltung' && istAdmin()) { - inhalt.appendChild(verwaltung()); + inhalt.appendChild(verwaltung(mediathekenNeuLaden)); } else { - inhalt.appendChild(startseite(oeffnen)); + inhalt.appendChild(startseite(mediatheken, oeffnen, alleZeigen)); } inhalt.appendChild(hinweisleiste()); diff --git a/web/app/src/style.css b/web/app/src/style.css index ec94e0e..cd701ef 100644 --- a/web/app/src/style.css +++ b/web/app/src/style.css @@ -73,6 +73,18 @@ button, input, select { font: inherit; color: inherit; } padding: var(--s-snug) var(--s-regular); border-radius: var(--r-control); color: var(--text-dim); font-size: 14px; } +/* Jede Mediathek eine Zeile: Name links, Zahl rechts. */ +#chrome button { display: flex; align-items: center; gap: var(--s-tight); } +#chrome button > span:first-child { + flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +#chrome button .zahl { + flex: none; font-size: 11px; font-variant-numeric: tabular-nums; + color: var(--text-muted); background: var(--elevated); + padding: 1px 6px; border-radius: 8px; +} +#chrome button.check .zahl { background: rgba(255, 255, 255, .22); color: #fff; } + #chrome button:hover { background: var(--elevated); color: var(--text); } #chrome button.check { background: var(--accent-dim); color: #fff; } @@ -122,6 +134,9 @@ button, input, select { font: inherit; color: inherit; } .seitentitel { margin: 0; font-size: 24px; letter-spacing: -.02em; } .row { display: flex; gap: var(--s-regular); align-items: center; margin-bottom: var(--s-section); } .row .field { max-width: 280px; margin-left: auto; } +/* Die Sortierung steht neben der Suche und braucht weniger Platz. */ +.row .field.schmal { max-width: 190px; margin-left: auto; } +.row .field.schmal + .field { margin-left: 0; } /* ---------- Karten und Raster ---------- */ @@ -169,9 +184,22 @@ button, input, select { font: inherit; color: inherit; } .reihe { margin-bottom: var(--s-section); } .reihe h2 { - font-size: 17px; font-weight: 600; margin: 0 0 var(--s-regular); + font-size: 17px; font-weight: 600; margin: 0; letter-spacing: -.01em; } + +/* Überschrift links, „Alle zeigen" rechts — steht mehr drin, als die Reihe + zeigt, soll die Reihe keine Sackgasse sein. */ +.reihe-kopf { + display: flex; align-items: baseline; gap: var(--s-regular); + margin-bottom: var(--s-regular); +} +.reihe-kopf .mehr { + margin-left: auto; flex: none; + background: none; border: 0; cursor: pointer; padding: 0; + font-size: 13px; color: var(--accent); +} +.reihe-kopf .mehr:hover { text-decoration: underline; } .strip { display: grid; grid-auto-flow: column; grid-auto-columns: var(--card-w); gap: var(--s-loose); overflow-x: auto; padding-bottom: var(--s-snug); diff --git a/web/app/src/views/admin.ts b/web/app/src/views/admin.ts index 10e2e08..9003621 100644 --- a/web/app/src/views/admin.ts +++ b/web/app/src/views/admin.ts @@ -1,19 +1,22 @@ import { - bibliotheken, bibliothekAnlegen, scanStarten, scanstand, + bibliotheken, bibliothekAnlegen, bibliothekUmbenennen, bibliothekLoeschen, + scanStarten, scanstand, benutzer, benutzerAnlegen, metadatenEinstellung, metadatenSetzen, type Bibliothek, } from '../api'; import { esc, knopf, feld, fehlerzeile, leer } from '../util'; -export function verwaltung(): HTMLElement { +/// `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(), benutzerbereich(), metadatenbereich()); + wurzel.append(bibliotheksbereich(geaendert), benutzerbereich(), metadatenbereich()); return wurzel; } /* ---------- Bibliotheken ---------- */ -function bibliotheksbereich(): HTMLElement { +function bibliotheksbereich(geaendert: () => void): HTMLElement { const bereich = document.createElement('section'); const h = document.createElement('h2'); h.textContent = 'Bibliotheken'; @@ -43,12 +46,69 @@ function bibliotheksbereich(): HTMLElement { function block(b: Bibliothek): HTMLElement { const d = document.createElement('div'); d.className = 'setting'; - d.innerHTML = `
${esc(b.name)}` - + `${esc(b.kind)} · ${esc(b.path)}
`; + + const kopf = document.createElement('div'); + kopf.className = 'rowline'; + // „1 Filme" ist der Satz, an dem man sieht, dass niemand hingeschaut hat. + const serie = b.kind === 'Shows'; + const anzahl = b.itemCount == null ? '' + : ` · ${b.itemCount} ` + (b.itemCount === 1 + ? (serie ? 'Serie' : 'Film') + : (serie ? 'Serien' : 'Filme')); + kopf.innerHTML = `${esc(b.name)}` + + `${esc(b.path)}${esc(anzahl)}`; + d.appendChild(kopf); const stand = document.createElement('div'); stand.className = 'hint'; 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 = 'btn-row'; + 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; @@ -79,7 +139,10 @@ function bibliotheksbereich(): HTMLElement { } } - d.append(los, stand); + const knoepfe = document.createElement('div'); + knoepfe.className = 'btn-row'; + knoepfe.append(los, umbenennen, entfernen); + d.append(knoepfe, stand); void verfolgen(); return d; } @@ -107,6 +170,7 @@ function bibliotheksbereich(): HTMLElement { 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; }; diff --git a/web/app/src/views/home.ts b/web/app/src/views/home.ts index d304d02..1d65259 100644 --- a/web/app/src/views/home.ts +++ b/web/app/src/views/home.ts @@ -1,6 +1,6 @@ import { - filme, serien, weiterschauen, naechsteFolgen, - type Film, type Serie, type Angefangen, + inhalt, weiterschauen, naechsteFolgen, + type Bibliothek, type Film, type Serie, type Angefangen, } from '../api'; import { esc, plakat, leer, fehlerzeile, anteil, verbleibend } from '../util'; @@ -8,29 +8,55 @@ type Art = 'filme' | 'serien'; /** Die Startseite: waagerechte Reihen, wie bei Plex. * - * Oben, was angefangen ist — darum geht man schliesslich hin. Darunter die - * Mediathek. Eine Reihe, die nichts enthält, wird gar nicht erst gezeigt. */ -export function startseite(oeffnen: (art: Art, id: string) => void): HTMLElement { + * Oben, was angefangen ist — darum geht man schliesslich hin. Darunter **je + * Mediathek eine Reihe** mit dem Zuletzt-Hinzugefügten. Nicht eine Reihe für + * alle Filme: wer „Filme" und „Dokumentationen" getrennt angelegt hat, hat + * sie getrennt gemeint, und eine Startseite, die sie wieder zusammenwirft, + * nimmt die Entscheidung zurück. + * + * Eine Reihe ohne Inhalt wird gar nicht erst gezeigt. */ +export function startseite( + bibliotheken: Bibliothek[], + oeffnen: (art: Art, id: string) => void, + alleZeigen: (b: Bibliothek) => void, +): HTMLElement { const wurzel = document.createElement('div'); wurzel.innerHTML = '
'; void (async () => { try { - const [weiter, naechste, f, s] = await Promise.all([ - weiterschauen(), naechsteFolgen(), filme({ take: 24 }), serien({ take: 24 }), + // **Alles auf einmal anfragen, nicht eine Reihe nach der anderen.** Bei + // fünf Mediatheken wären das sonst sieben Anfragen nacheinander, und + // die Startseite baut sich vor den Augen des Benutzers Zeile für Zeile + // auf. + const [weiter, naechste, ...bestaende] = await Promise.all([ + weiterschauen(), + naechsteFolgen(), + ...bibliotheken.map(b => inhalt(b, { sort: 'added', take: 24 })), ]); + wurzel.innerHTML = ''; - if (f.total === 0 && s.total === 0) { - wurzel.appendChild(leer('Noch nichts da. Lege unter Verwaltung eine Bibliothek an ' + if (bibliotheken.length === 0) { + wurzel.appendChild(leer('Noch keine Mediathek. Lege unter Verwaltung eine an ' + 'und starte einen Scan.')); return; } - if (weiter.items.length) wurzel.appendChild(angefangenReihe('Weiterschauen', weiter)); - if (naechste.items.length) wurzel.appendChild(angefangenReihe('Nächste Folge', naechste)); - if (f.total > 0) wurzel.appendChild(reihe(`Filme · ${f.total}`, f.items, 'filme')); - if (s.total > 0) wurzel.appendChild(reihe(`Serien · ${s.total}`, s.items, 'serien')); + if (weiter.items.length) wurzel.appendChild(angefangenReihe('Weiterschauen', weiter.items)); + if (naechste.items.length) wurzel.appendChild(angefangenReihe('Nächste Folge', naechste.items)); + + let etwasDa = false; + bibliotheken.forEach((b, i) => { + const seite = bestaende[i]; + if (!seite?.items.length) return; + etwasDa = true; + wurzel.appendChild(reihe(b, seite.items, seite.total)); + }); + + if (!etwasDa && !weiter.items.length && !naechste.items.length) { + wurzel.appendChild(leer('Noch nichts da. Starte unter Verwaltung einen Scan.')); + } } catch (fehler) { wurzel.innerHTML = ''; wurzel.appendChild(fehlerzeile((fehler as Error).message)); @@ -38,17 +64,12 @@ export function startseite(oeffnen: (art: Art, id: string) => void): HTMLElement })(); /** Eine Reihe aus Einträgen mit Stand — Filme und Folgen gemischt. */ - function angefangenReihe(titel: string, seite: { items: Angefangen[] }): HTMLElement { - const bereich = document.createElement('section'); - bereich.className = 'reihe'; - - const h = document.createElement('h2'); - h.textContent = titel; - + function angefangenReihe(titel: string, eintraege: Angefangen[]): HTMLElement { + const bereich = kopfzeile(titel); const streifen = document.createElement('div'); streifen.className = 'strip'; - seite.items.forEach(eintrag => { + eintraege.forEach(eintrag => { const k = document.createElement('button'); k.className = 'card'; k.appendChild(plakat(eintrag.posterImage, eintrag.title, anteil(eintrag.state))); @@ -71,16 +92,16 @@ export function startseite(oeffnen: (art: Art, id: string) => void): HTMLElement streifen.appendChild(k); }); - bereich.append(h, streifen); + bereich.appendChild(streifen); return bereich; } - function reihe(titel: string, eintraege: (Film | Serie)[], art: Art): HTMLElement { - const bereich = document.createElement('section'); - bereich.className = 'reihe'; - - const h = document.createElement('h2'); - h.textContent = titel; + function reihe(b: Bibliothek, eintraege: (Film | Serie)[], gesamt: number): HTMLElement { + const art: Art = b.kind === 'Shows' ? 'serien' : 'filme'; + // Steht mehr drin, als die Reihe zeigt, führt ein Weg dorthin. Ohne ihn + // wäre die Reihe eine Sackgasse mit vierundzwanzig Kacheln. + const bereich = kopfzeile( + b.name, gesamt > eintraege.length ? () => alleZeigen(b) : undefined, gesamt); const streifen = document.createElement('div'); streifen.className = 'strip'; @@ -109,7 +130,30 @@ export function startseite(oeffnen: (art: Art, id: string) => void): HTMLElement streifen.appendChild(k); }); - bereich.append(h, streifen); + bereich.appendChild(streifen); + return bereich; + } + + function kopfzeile(titel: string, mehr?: () => void, gesamt?: number): HTMLElement { + const bereich = document.createElement('section'); + bereich.className = 'reihe'; + + const zeile = document.createElement('div'); + zeile.className = 'reihe-kopf'; + + const h = document.createElement('h2'); + h.textContent = gesamt != null ? `${titel} · ${gesamt}` : titel; + zeile.appendChild(h); + + if (mehr) { + const knopf = document.createElement('button'); + knopf.className = 'mehr'; + knopf.textContent = 'Alle zeigen'; + knopf.onclick = mehr; + zeile.appendChild(knopf); + } + + bereich.appendChild(zeile); return bereich; } diff --git a/web/app/src/views/library.ts b/web/app/src/views/library.ts index fa53f5c..118fe55 100644 --- a/web/app/src/views/library.ts +++ b/web/app/src/views/library.ts @@ -1,19 +1,25 @@ -import { filme, serien, type Film, type Serie } from '../api'; +import { inhalt, type Bibliothek, type Film, type Serie, type Ordnung } from '../api'; import { esc, plakat, leer, fehlerzeile, anteil } from '../util'; type Art = 'filme' | 'serien'; -/** Eine Bibliothek als Raster. +/** Eine Mediathek als Raster. * - * Welche Art gezeigt wird, entscheidet die Seitenleiste — wie bei Plex, wo - * jede Bibliothek ihr eigener Punkt in der Navigation ist. Eine zweite - * Umschaltung innerhalb der Seite wäre dieselbe Entscheidung an zwei Orten. + * **Eine Ansicht je Mediathek, nicht eine je Art.** Welche gezeigt wird, + * entscheidet die Seitenleiste — wie bei Plex, wo jede Mediathek ihr eigener + * Punkt in der Navigation ist. Wer „Filme" und „Dokumentationen" getrennt + * angelegt hat, hat sie getrennt gemeint. * - * Gesucht wird am Server, nicht im Browser: bei zehntausend Titeln wäre - * alles zu laden und dann zu filtern der falsche Weg. */ -export function mediathek(art: Art, oeffnen: (art: Art, id: string) => void): HTMLElement { + * Gesucht und sortiert wird am Server: bei zehntausend Titeln wäre alles zu + * laden und dann zu sieben der falsche Weg. */ +export function mediathek( + bibliothek: Bibliothek, + oeffnen: (art: Art, id: string) => void, +): HTMLElement { const wurzel = document.createElement('div'); + const art: Art = bibliothek.kind === 'Shows' ? 'serien' : 'filme'; let suche = ''; + let ordnung: Ordnung = 'title'; let tippt: number | undefined; const kopf = document.createElement('div'); @@ -21,12 +27,26 @@ export function mediathek(art: Art, oeffnen: (art: Art, id: string) => void): HT const titel = document.createElement('h1'); titel.className = 'seitentitel'; - titel.textContent = art === 'filme' ? 'Filme' : 'Serien'; + titel.textContent = bibliothek.name; + + const sortierung = document.createElement('select'); + sortierung.className = 'field schmal'; + ([ + ['title', 'A–Z'], + ['added', 'Zuletzt hinzugefügt'], + ['year', 'Jahr'], + ] as [Ordnung, string][]).forEach(([wert, text]) => { + const o = document.createElement('option'); + o.value = wert; + o.textContent = text; + sortierung.appendChild(o); + }); + sortierung.onchange = () => { ordnung = sortierung.value as Ordnung; void laden(); }; const suchfeld = document.createElement('input'); suchfeld.className = 'field'; suchfeld.type = 'search'; - suchfeld.placeholder = 'Suchen'; + suchfeld.placeholder = `In ${bibliothek.name} suchen`; suchfeld.oninput = () => { // Nicht bei jedem Tastendruck fragen: das wären bei „Untergang" neun // Anfragen, von denen acht niemanden interessieren. @@ -34,7 +54,7 @@ export function mediathek(art: Art, oeffnen: (art: Art, id: string) => void): HT tippt = window.setTimeout(() => { suche = suchfeld.value.trim(); void laden(); }, 250); }; - kopf.append(titel, suchfeld); + kopf.append(titel, sortierung, suchfeld); const raster = document.createElement('div'); raster.className = 'grid'; @@ -45,15 +65,13 @@ export function mediathek(art: Art, oeffnen: (art: Art, id: string) => void): HT raster.innerHTML = ''; raster.appendChild(ladeanzeige()); try { - const seite = art === 'filme' - ? await filme({ search: suche, take: 100 }) - : await serien({ search: suche, take: 100 }); + const seite = await inhalt(bibliothek, { search: suche, sort: ordnung, take: 100 }); raster.innerHTML = ''; if (seite.items.length === 0) { raster.appendChild(leer(suche ? `Nichts gefunden für „${suche}“.` - : 'Noch nichts da. Lege unter Verwaltung eine Bibliothek an und starte einen Scan.')); + : `„${bibliothek.name}“ ist leer. Starte unter Verwaltung einen Scan.`)); return; } seite.items.forEach(eintrag => raster.appendChild(karte(eintrag)));