Mehrere Mediatheken, jede für sich
Bisher gab es in der Seitenleiste „Filme" und „Serien" — zwei feste Zeilen, hinter denen alles zusammenfiel, was der Server kannte. Wer „Filme" und „Dokumentationen" getrennt angelegt hatte, sah sie wieder in einem Topf. Das nahm die Entscheidung zurück, sie zu trennen. Jetzt führt die Seitenleiste **jede Mediathek einzeln**, mit Namen und Zahl, wie bei Plex und Jellyfin. Die Startseite bekommt je Mediathek eine Reihe mit dem zuletzt Hinzugefügten; steht mehr drin, als die Reihe zeigt, führt „Alle zeigen" dorthin. Die Mediathekenseite trägt den Namen im Titel, sucht innerhalb der Mediathek und lässt sich sortieren. **Am Server dafür:** `?sort=` auf `/movies` und `/series` — `title`, `added`, `year`. Der Titel immer als letzter Vergleich, sonst hätten zwei Filme aus demselben Jahr keine feste Reihenfolge und Seite 2 zeigte denselben Film noch einmal oder gar nicht. Die Liste der Bibliotheken nennt je Zeile einen Zählwert. „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. `null` heisst „nicht gezählt", nicht „leer". Umbenennen und Entfernen, beides nur für Administratoren. **Umbenennen, mehr nicht:** die Art zu ändern hiesse, Filme in Serien zu verwandeln; den Pfad zu ändern hiesse, einen Bestand gegen einen anderen zu tauschen, während die Einträge stehenbleiben. Beides ist kein Bearbeiten, sondern eine neue Bibliothek. **Beim Entfernen bleiben die Mediendateien liegen.** Weg sind der Eintrag, die Zuordnungen und der Sehfortschritt aller Benutzer dazu; die Oberfläche sagt das vorher, und eine Prüfung hält fest, dass die Datei danach noch da ist. Ein Server, der beim Aufräumen in der Oberfläche fremde Filme löscht, ist einer, den man nicht noch einmal benutzt. --- **Der Fehler, den der Browser zeigte, und er betrifft jeden Betrieb:** nach einem Server-Update lief weiter die alte Oberfläche. Die Dateien unter `assets/` tragen eine Prüfsumme im Namen — ändert sich der Inhalt, ändert sich der Name. `index.html` trägt keine; sie nennt nur deren Namen. Lag sie im Zwischenspeicher, bekam der Browser weiter die alte App: nicht kaputt, sondern veraltet, und das fällt niemandem auf. Nachgemessen: geladen war `index-C0K3V8eg.js`, ausgeliefert wurde längst `index-DomHN4_b.js`. Jetzt zwei Lebensdauern — `no-cache` für die Seite, ein Jahr und `immutable` für die Bausteine. Mit Prüfung, und am Container nachgemessen. Der Beispielbestand bekommt zwei weitere Mediatheken, „Dokumentationen" und „Konzerte". Mit nur Filme und Serien liesse sich gar nicht prüfen, ob mehrere wirklich getrennt bleiben — zwei Bestände **derselben Art** sind der Fall, in dem es auffällt. 358 Tests grün. Am Container nachgewiesen: vier Mediatheken in der Seitenleiste mit Zählwerten, je eine Reihe auf der Startseite, und „Dokumentationen" zeigt zwei Titel statt sieben. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
172b38da06
commit
230beb9664
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
/// <summary>Eine Bibliothek, wie die Oberfläche sie zeigt.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <c>ItemCount</c> ist <c>null</c>, wenn er nicht mitgezählt wurde — etwa
|
||||
/// direkt nach dem Anlegen oder Umbenennen. Nicht <c>0</c>: „keine Titel" und
|
||||
/// „nicht gezählt" sind verschiedene Auskünfte, und eine frisch angelegte
|
||||
/// Bibliothek als leer auszugeben wäre die falsche.
|
||||
/// </remarks>
|
||||
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);
|
||||
|
||||
|
||||
@@ -16,6 +16,24 @@ public static class LibraryContentEndpoints
|
||||
/// </remarks>
|
||||
private const int HöchstensJeSeite = 200;
|
||||
|
||||
/// <summary>Wonach eine Liste geordnet wird.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Als Wort, nicht als Spaltenname.</b> „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.
|
||||
/// </remarks>
|
||||
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();
|
||||
|
||||
@@ -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<string, string[]>
|
||||
{
|
||||
["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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Flaro.Api.Endpoints;
|
||||
|
||||
namespace Flaro.Api.Tests;
|
||||
|
||||
/// <summary>Mehrere Mediatheken nebeneinander — wie bei Plex und Jellyfin.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Zwei Filmbibliotheken sind keine doppelte Filmbibliothek.</b> „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.
|
||||
/// </remarks>
|
||||
public class MultipleLibraryTests
|
||||
{
|
||||
private readonly List<string> _ordner = [];
|
||||
|
||||
[TearDown]
|
||||
public void Aufraeumen()
|
||||
{
|
||||
foreach (var o in _ordner)
|
||||
{
|
||||
try { Directory.Delete(o, recursive: true); } catch (IOException) { /* Kür */ }
|
||||
}
|
||||
_ordner.Clear();
|
||||
}
|
||||
|
||||
/// <summary>Ein Ordner mit den genannten Dateien darin.</summary>
|
||||
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<LibraryResponse> 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<LibraryResponse>())!;
|
||||
}
|
||||
|
||||
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<ScanStatus>($"/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<Page<MovieResponse>>($"/api/v1/movies?library={a.Id}");
|
||||
var inB = await client.GetFromJsonAsync<Page<MovieResponse>>($"/api/v1/movies?library={b.Id}");
|
||||
var alle = await client.GetFromJsonAsync<Page<MovieResponse>>("/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));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Die Seitenleiste braucht zu jeder Zeile eine Zahl.</summary>
|
||||
[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<List<LibraryResponse>>("/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<LibraryResponse>();
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>Entfernt wird der Eintrag — nicht der Film auf der Platte.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Das ist die wichtigste Zusage dieses Servers.</b> Ein Server, der
|
||||
/// beim Aufräumen in der Oberfläche fremde Mediendateien löscht, ist
|
||||
/// einer, den man nicht noch einmal benutzt.
|
||||
/// </remarks>
|
||||
[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<Page<MovieResponse>>("/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<Page<MovieResponse>>("/api/v1/movies");
|
||||
var liste = await client.GetFromJsonAsync<List<LibraryResponse>>("/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));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>„Zuletzt hinzugefügt" braucht eine andere Reihenfolge als A–Z.</summary>
|
||||
[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<Page<MovieResponse>>("/api/v1/movies");
|
||||
var nachAufnahme = await client.GetFromJsonAsync<Page<MovieResponse>>("/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");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -140,6 +140,49 @@ public sealed class WebInterfaceTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Die Seite darf nicht im Zwischenspeicher hängenbleiben.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Der Fehler, den der Browser zeigte.</b> Die Dateien unter
|
||||
/// <c>assets/</c> tragen eine Prüfsumme im Namen; <c>index.html</c> 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 <c>index-C0K3V8eg.js</c>, ausgeliefert wurde
|
||||
/// längst <c>index-DomHN4_b.js</c>.
|
||||
/// </remarks>
|
||||
[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"), "<!doctype html><title>Flaro</title>");
|
||||
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 */ }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Ein unbekannter Pfad unter /api ist ein Fehler, keine Seite.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
|
||||
+35
-5
@@ -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<T> = { 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<Seite<Film>>(`/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<Seite<Serie>>(`/series${abfrage(q)}`);
|
||||
type Suchfrage = {
|
||||
library?: string; search?: string; sort?: Ordnung; skip?: number; take?: number;
|
||||
};
|
||||
|
||||
export const filme = (q: Suchfrage = {}) => anfrage<Seite<Film>>(`/movies${abfrage(q)}`);
|
||||
|
||||
export const serien = (q: Suchfrage = {}) => anfrage<Seite<Serie>>(`/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<Suchfrage, 'library'> = {}) =>
|
||||
b.kind === 'Shows'
|
||||
? serien({ ...q, library: b.id })
|
||||
: filme({ ...q, library: b.id });
|
||||
|
||||
export const folgen = (serie: string, staffel?: number) =>
|
||||
anfrage<Seite<Folge>>(`/series/${serie}/episodes${abfrage({ season: staffel })}`);
|
||||
@@ -226,6 +247,15 @@ export const downloadAdresse = (datei: string) => `/api/v1/files/${datei}/downlo
|
||||
|
||||
export const bibliotheken = () => anfrage<Bibliothek[]>('/libraries');
|
||||
|
||||
export const bibliothekUmbenennen = (id: string, name: string) =>
|
||||
anfrage<Bibliothek>(`/libraries/${id}`, {
|
||||
method: 'PUT', body: JSON.stringify({ name }),
|
||||
});
|
||||
|
||||
/** Entfernt den Eintrag — die Mediendateien bleiben liegen. */
|
||||
export const bibliothekLoeschen = (id: string) =>
|
||||
anfrage<void>(`/libraries/${id}`, { method: 'DELETE' });
|
||||
|
||||
export const bibliothekAnlegen = (name: string, art: string, pfad: string) =>
|
||||
anfrage<Bibliothek>('/libraries', {
|
||||
method: 'POST', body: JSON.stringify({ name, kind: art, path: pfad }),
|
||||
|
||||
+43
-13
@@ -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<void> {
|
||||
|
||||
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<void> {
|
||||
`<div class="login-card"><h1>Flaro</h1>`
|
||||
+ `<div class="error">Der Server antwortet nicht: ${esc((fehler as Error).message)}</div>`
|
||||
+ `<div class="hint">Läuft er? Stimmt die Adresse?</div></div>`;
|
||||
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<void> {
|
||||
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 = `<span>${esc(text)}</span>`
|
||||
+ (anzahl != null ? `<span class="zahl">${anzahl}</span>` : '');
|
||||
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());
|
||||
|
||||
+29
-1
@@ -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);
|
||||
|
||||
@@ -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 = `<div class="rowline"><strong>${esc(b.name)}</strong>`
|
||||
+ `<span class="tiny">${esc(b.kind)} · ${esc(b.path)}</span></div>`;
|
||||
|
||||
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 = `<strong>${esc(b.name)}</strong>`
|
||||
+ `<span class="tiny">${esc(b.path)}${esc(anzahl)}</span>`;
|
||||
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;
|
||||
};
|
||||
|
||||
+73
-29
@@ -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 = '<div class="spinner"></div>';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)));
|
||||
|
||||
Reference in New Issue
Block a user