Der Abschnitt verschwand alle drei Sekunden und kam wieder

`laden()` setzte `innerHTML = ''` und baute die Mediathekenliste neu —
während eines Scans alle drei Sekunden. Der ganze Abschnitt flackerte
sichtbar, und wer gerade auf einen Knopf zielte, klickte ins Leere.
Schlimmer: jeder Neuaufbau startete über `verfolgen(false)` einen
weiteren Takt. Nach einer Minute liefen zwanzig davon nebeneinander.

Jetzt wird fortgeschrieben statt neu gezeichnet: eine Zeile je
Mediathek, die bleibt, und nur Kopfzeile, Prüfungsknopf und
Freigabeknopf ändern sich. Belegt im Browser — nach einem Auffrischen
sind es dieselben Knoten, nur die Beschriftung ist eine andere.

Dieselbe Behandlung für die Seitenleiste. Und weil Seitenleiste und
Verwaltung getrennt nachfragten, zeigten sie zwei Momente — 95 hier, 84
dort, während im Hintergrund Doppelte zusammengelegt wurden. Die
Verwaltung reicht ihren Stand jetzt weiter: ein Abruf, zwei Anzeigen.

Dazu zwei Dinge, die beim Prüfen auffielen. Zurücksetzen und Entfernen
prallen jetzt serverseitig mit 409 ab, solange ein Lauf läuft — die
Oberfläche sperrt die Knöpfe, aber darauf darf sich der Server nicht
verlassen; ein Aufruf von Hand während eines Scans liess den Lauf an
einer DbUpdateConcurrencyException sterben. Und in der
Fortschrittszeile steht beim Holen der Folgenmetadaten jetzt der
Serienname: „Folge 2×13" allein sagt bei 3815 Folgen nichts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-04 18:21:43 +02:00
co-authored by Claude Opus 5
parent a6e9a6e425
commit 960cca3dae
16 changed files with 311 additions and 85 deletions
+1
View File
@@ -0,0 +1 @@
<svg fill="none" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path clip-rule="evenodd" d="m11.0721 5.13152c.3321-.84203 1.5237-.84203 1.8558 0l1.6803 4.26031 4.2603 1.68027c.842.3321.842 1.5237 0 1.8558l-4.2603 1.6803-1.6803 4.2603c-.3321.842-1.5237.842-1.8558 0l-1.68027-4.2603-4.26031-1.6803c-.84203-.3321-.84203-1.5237 0-1.8558l4.26031-1.68027zm.9279 1.73562-1.2928 3.27806c-.1014.2571-.3049.4606-.562.562l-3.27806 1.2928 3.27806 1.2928c.2571.1014.4606.3049.562.562l1.2928 3.2781 1.2928-3.2781c.1014-.2571.3049-.4606.562-.562l3.2781-1.2928-3.2781-1.2928c-.2571-.1014-.4606-.3049-.562-.562z" fill="rgb(0,0,0)" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 673 B

-1
View File
@@ -1 +0,0 @@
<svg id="Layer_1" height="512" viewBox="0 0 512 512" width="512" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" data-name="Layer 1"><linearGradient id="linear-gradient" gradientUnits="userSpaceOnUse" x1="-52.588" x2="390.826" y1="178.158" y2="434.163"><stop offset="0" stop-color="#55d6ff"/><stop offset="1" stop-color="#ce55ff"/></linearGradient><path d="m485.036 256a54.706 54.706 0 0 1 -27.636 47.868l-347.527 200.644a54.742 54.742 0 0 1 -55.272 0 54.713 54.713 0 0 1 -27.637-47.868v-401.288a54.718 54.718 0 0 1 27.636-47.869 54.75 54.75 0 0 1 55.273 0l347.527 200.645a54.708 54.708 0 0 1 27.636 47.868z" fill="url(#linear-gradient)"/></svg>

Before

Width:  |  Height:  |  Size: 675 B

+7 -7
View File
@@ -2,7 +2,7 @@
"fill-specializations" : [ "fill-specializations" : [
{ {
"value" : { "value" : {
"automatic-gradient" : "display-p3:0.44346,0.27892,0.96783,1.00000" "automatic-gradient" : "display-p3:0.56409,0.38672,0.96783,1.00000"
} }
}, },
{ {
@@ -17,15 +17,15 @@
"layers" : [ "layers" : [
{ {
"fill" : { "fill" : {
"automatic-gradient" : "display-p3:0.73918,0.46760,0.96783,1.00000" "automatic-gradient" : "display-p3:0.51566,0.11264,0.96783,1.00000"
}, },
"image-name" : "play.svg", "image-name" : "flare (1).svg",
"name" : "play", "name" : "flare (1)",
"position" : { "position" : {
"scale" : 1.18, "scale" : 100,
"translation-in-points" : [ "translation-in-points" : [
49.7265625, 0,
1.468530916781674e-05 0
] ]
} }
} }
@@ -282,11 +282,22 @@ public static class ManagementEndpoints
// sie weiss: Titel, Zuordnungen, Fortschritt. Ein Server, der beim // sie weiss: Titel, Zuordnungen, Fortschritt. Ein Server, der beim
// Aufräumen in der Oberfläche fremde Filme löscht, ist ein Server, // Aufräumen in der Oberfläche fremde Filme löscht, ist ein Server,
// den man nicht noch einmal benutzt. // den man nicht noch einmal benutzt.
bibliotheken.MapDelete("/{id:guid}", async (Guid id, FlaroContext db) => bibliotheken.MapDelete("/{id:guid}", async (
Guid id, FlaroContext db, ScanCoordinator koordinator) =>
{ {
var vorhanden = await db.Libraries.FindAsync(id); var vorhanden = await db.Libraries.FindAsync(id);
if (vorhanden is null) return Results.NotFound(); if (vorhanden is null) return Results.NotFound();
// Aus demselben Grund wie beim Zurücksetzen — nur dass hier die
// ganze Mediathek unter dem laufenden Scan verschwände.
if (koordinator.IsRunning(id))
{
return Results.Problem(
"Für diese Mediathek läuft gerade ein Scan. "
+ "Entfernen geht erst, wenn er durch ist.",
statusCode: StatusCodes.Status409Conflict);
}
db.Libraries.Remove(vorhanden); db.Libraries.Remove(vorhanden);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Results.NoContent(); return Results.NoContent();
@@ -302,11 +313,27 @@ public static class ManagementEndpoints
// //
// Nicht gelöscht wird eine einzige Mediendatei. Entfernt wird allein, // Nicht gelöscht wird eine einzige Mediendatei. Entfernt wird allein,
// was Flaro über sie weiss. // was Flaro über sie weiss.
bibliotheken.MapPost("/{id:guid}/reset", async (Guid id, FlaroContext db) => bibliotheken.MapPost("/{id:guid}/reset", async (
Guid id, FlaroContext db, ScanCoordinator koordinator) =>
{ {
var vorhanden = await db.Libraries.FindAsync(id); var vorhanden = await db.Libraries.FindAsync(id);
if (vorhanden is null) return Results.NotFound(); if (vorhanden is null) return Results.NotFound();
// **Nicht während eines Laufs.**
//
// Die Oberfläche sperrt den Knopf, aber darauf darf sich der
// Server nicht verlassen: ein zweiter Administrator oder ein
// Aufruf von Hand genügt. Wer Zeilen löscht, während der Scan
// welche schreibt, bekommt einen Lauf, der mitten im Schreiben
// abbricht — und einen Bestand, den niemand mehr erklären kann.
if (koordinator.IsRunning(id))
{
return Results.Problem(
"Für diese Mediathek läuft gerade ein Scan. "
+ "Zurücksetzen geht erst, wenn er durch ist.",
statusCode: StatusCodes.Status409Conflict);
}
// In dieser Reihenfolge: erst die Dateien, dann die Titel. Die // In dieser Reihenfolge: erst die Dateien, dann die Titel. Die
// Fremdschlüssel zeigen von den Dateien auf Film und Folge. // Fremdschlüssel zeigen von den Dateien auf Film und Folge.
await db.MediaFiles.Where(f => f.LibraryId == id).ExecuteDeleteAsync(); await db.MediaFiles.Where(f => f.LibraryId == id).ExecuteDeleteAsync();
+5
View File
@@ -57,6 +57,11 @@ public static partial class Fehlertext
+ "Oft ist das ein volles Volume oder ein schlafendes Laufwerk.", + "Oft ist das ein volles Volume oder ein schlafendes Laufwerk.",
TaskCanceledException or OperationCanceledException => TaskCanceledException or OperationCanceledException =>
"Der Vorgang wurde abgebrochen.", "Der Vorgang wurde abgebrochen.",
// Tritt auf, wenn jemand dieselben Zeilen gleichzeitig entfernt —
// etwa ein „Zurücksetzen" während eines laufenden Scans.
Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException =>
"Jemand hat dieselben Einträge gleichzeitig geändert. "
+ "Der Lauf wurde abgebrochen; starte ihn noch einmal.",
HttpRequestException => HttpRequestException =>
"Die Metadatenquelle war nicht erreichbar. " "Die Metadatenquelle war nicht erreichbar. "
+ "Beim nächsten Lauf wird es erneut versucht.", + "Beim nächsten Lauf wird es erneut versucht.",
+13 -5
View File
@@ -325,9 +325,12 @@ public sealed class MetadataEnricher(
private async Task<int> FolgenZuordnen( private async Task<int> FolgenZuordnen(
Guid libraryId, bool erneut, IProgress<Scanschritt>? fortschritt, CancellationToken ct) Guid libraryId, bool erneut, IProgress<Scanschritt>? fortschritt, CancellationToken ct)
{ {
// **Mit Titel, nicht nur mit Kennung.** In der Fortschrittszeile stand
// „Hole Metadaten zu Folge 2×13" — und bei 3815 Folgen aus achtzig
// Serien sagt eine Folgennummer ohne Serie überhaupt nichts.
var serien = await db.Series var serien = await db.Series
.Where(s => s.LibraryId == libraryId && s.TmdbId != null) .Where(s => s.LibraryId == libraryId && s.TmdbId != null)
.Select(s => new { s.Id, TmdbId = s.TmdbId!.Value }) .Select(s => new { s.Id, TmdbId = s.TmdbId!.Value, s.Title })
.ToListAsync(ct); .ToListAsync(ct);
if (serien.Count == 0) return 0; if (serien.Count == 0) return 0;
@@ -336,16 +339,21 @@ public sealed class MetadataEnricher(
.Where(f => kennungen.Contains(f.SeriesId) && (erneut || f.Overview == null)) .Where(f => kennungen.Contains(f.SeriesId) && (erneut || f.Overview == null))
.ToListAsync(ct); .ToListAsync(ct);
// Einmal nachschlagbar machen: `First` je Folge wäre bei 3815 Folgen
// und achtzig Serien eine Viertelmillion Vergleiche.
var nachKennung = serien.ToDictionary(s => s.Id);
var zugeordnet = 0; var zugeordnet = 0;
var durch = 0; var durch = 0;
foreach (var folge in offen) foreach (var folge in offen)
{ {
ct.ThrowIfCancellationRequested();
if (!nachKennung.TryGetValue(folge.SeriesId, out var serie)) continue;
fortschritt?.Report(new Scanschritt( fortschritt?.Report(new Scanschritt(
Scanphase.Holen, Scanphase.Holen,
$"Folge {folge.SeasonNumber}x{folge.EpisodeNumber:00}", ++durch, offen.Count)); $"{serie.Title} {folge.SeasonNumber}x{folge.EpisodeNumber:00}",
++durch, offen.Count));
ct.ThrowIfCancellationRequested();
var serie = serien.First(s => s.Id == folge.SeriesId);
var gefunden = await anbieter.FindEpisodeAsync(serie.TmdbId, folge.SeasonNumber, folge.EpisodeNumber, ct); var gefunden = await anbieter.FindEpisodeAsync(serie.TmdbId, folge.SeasonNumber, folge.EpisodeNumber, ct);
if (gefunden is null) continue; if (gefunden is null) continue;
+26
View File
@@ -500,6 +500,32 @@ public class ScanTests
Assert.That(offen.Dateien[0].Grund, Does.Contain("keine Folgennummer")); Assert.That(offen.Dateien[0].Grund, Does.Contain("keine Folgennummer"));
} }
/// <summary>Zurücksetzen und Entfernen prallen ab, solange ein Lauf läuft.</summary>
///
/// <remarks>
/// <b>Die Oberfläche sperrt die Knöpfe, aber darauf darf sich der Server nicht
/// verlassen.</b> Beim Prüfen von Hand ist genau das passiert: ein
/// „Zurücksetzen" per curl während eines Scans, und der Lauf starb an einer
/// DbUpdateConcurrencyException. Ein zweiter Administrator genügt dafür auch.
/// </remarks>
[Test]
public async Task Zuruecksetzen_waehrend_eines_Scans_wird_abgelehnt()
{
for (var i = 1; i <= 400; i++) Datei($"Dark/Staffel 1/Dark - S01E{i:D3}.mkv");
using var server = new FlaroServer();
var (client, bibliothek) = await MitBibliothek(server, "Shows");
var gestartet = await client.PostAsync($"/api/v1/libraries/{bibliothek}/scan", null);
Assert.That(gestartet.StatusCode, Is.EqualTo(HttpStatusCode.Accepted));
var zurueck = await client.PostAsync($"/api/v1/libraries/{bibliothek}/reset", null);
var weg = await client.DeleteAsync($"/api/v1/libraries/{bibliothek}");
Assert.That(zurueck.StatusCode, Is.EqualTo(HttpStatusCode.Conflict));
Assert.That(weg.StatusCode, Is.EqualTo(HttpStatusCode.Conflict));
}
[Test] [Test]
public async Task Die_Suche_grenzt_ein() public async Task Die_Suche_grenzt_ein()
{ {
+13
View File
@@ -5,6 +5,19 @@
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#0B0B0D"> <meta name="theme-color" content="#0B0B0D">
<title>Flaro</title> <title>Flaro</title>
<!-- **Dasselbe Zeichen wie das App-Icon**, erzeugt aus
`Flaro/AppIcon.icon` — gleiche Grafik, gleiche Farben, von Display-P3
nach sRGB umgerechnet. Zwei Icons, die sich ähnlich sehen, driften
auseinander; eins, das aus demselben Ordner kommt, nicht.
SVG zuerst, weil es in jeder Grösse scharf bleibt. Das PNG daneben
für alles, was kein SVG-Favicon kann — und für den Startbildschirm
auf dem Telefon, der ohnehin nur PNG nimmt. -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="icon" type="image/png" sizes="32x32" href="/icon-32.png">
<link rel="apple-touch-icon" href="/icon-180.png">
<link rel="manifest" href="/manifest.webmanifest">
<!-- **Inter, mit der Systemschrift als Rückfall.** `display=swap`, damit der <!-- **Inter, mit der Systemschrift als Rückfall.** `display=swap`, damit der
Text sofort steht und nicht erst, wenn die Schrift da ist — ein Text sofort steht und nicht erst, wenn die Schrift da ist — ein
Heimserver im eigenen Netz soll nicht auf Google warten. --> Heimserver im eigenen Netz soll nicht auf Google warten. -->
+28
View File
@@ -0,0 +1,28 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Flaro">
<!--
Aus Flaro/AppIcon.icon erzeugt, nicht nachgezeichnet: dieselbe Grafik
(Assets/flare (1).svg) und dieselben Farben, von Display-P3 nach sRGB
umgerechnet. Ändert sich das App-Icon, wird diese Datei neu erzeugt.
#9860FF ist der helle Hintergrund des Icons, #261653 der dunkle.
Der Verlauf ahmt Icon Composers „automatic gradient" nach: oben heller.
-->
<defs>
<linearGradient id="grund" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#B98CFF"/>
<stop offset="1" stop-color="#8243F5"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="14" fill="url(#grund)"/>
<!--
Die Glyphe weiss statt im Originalton (#900AFF). Auf dem Plakat eines
App-Icons trägt sie das Glas; bei 16 Bildpunkten im Browsertab wäre
Violett auf Violett ein Fleck.
Skaliert: 24er-Raster auf 40 Punkte, zentriert.
-->
<g transform="translate(12 12) scale(1.6667)" fill="#FFFFFF">
<path clip-rule="evenodd" fill-rule="evenodd" d="m11.0721 5.13152c.3321-.84203 1.5237-.84203 1.8558 0l1.6803 4.26031 4.2603 1.68027c.842.3321.842 1.5237 0 1.8558l-4.2603 1.6803-1.6803 4.2603c-.3321.842-1.5237.842-1.8558 0l-1.68027-4.2603-4.26031-1.6803c-.84203-.3321-.84203-1.5237 0-1.8558l4.26031-1.68027zm.9279 1.73562-1.2928 3.27806c-.1014.2571-.3049.4606-.562.562l-3.27806 1.2928 3.27806 1.2928c.2571.1014.4606.3049.562.562l1.2928 3.2781 1.2928-3.2781c.1014-.2571.3049-.4606.562-.562l3.2781-1.2928-3.2781-1.2928c-.2571-.1014-.4606-.3049-.562-.562z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

+13
View File
@@ -0,0 +1,13 @@
{
"name": "Flaro",
"short_name": "Flaro",
"start_url": "/",
"display": "standalone",
"background_color": "#0B0B0D",
"theme_color": "#0B0B0D",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
+32 -5
View File
@@ -86,9 +86,33 @@ export async function mediathekenNeuLaden(): Promise<void> {
* Endlosschleife mit zwei Abrufen je Runde, die sich als vier dauernd * Endlosschleife mit zwei Abrufen je Runde, die sich als vier dauernd
* drehende Spinner zeigte. Ein neuer Zählwert in der Seitenleiste braucht * drehende Spinner zeigte. Ein neuer Zählwert in der Seitenleiste braucht
* keinen Neuaufbau der Seite. */ * keinen Neuaufbau der Seite. */
export async function zaehlerAuffrischen(): Promise<void> { export async function zaehlerAuffrischen(bekannt?: Bibliothek[]): Promise<void> {
mediatheken = await bibliotheken(); // **Denselben Stand zeigen, nicht denselben Takt haben.**
leisteBauen(); //
// Beide Anzeigen fragten getrennt nach — und zeigten damit zwei Momente:
// in der Seitenleiste stand 95, in der Verwaltung 84. Während die
// Metadaten laufen, legt der Server Doppelte zusammen, die Zahl fällt also
// laufend. Wer hier eine Liste mitgibt, hat sie gerade selbst geholt; dann
// wird sie benutzt statt eine zweite zu ziehen.
const frisch = bekannt ?? await bibliotheken();
// **Nur die Zahlen, solange die Liste dieselbe ist.**
//
// `leisteBauen()` ersetzt die ganze Seitenleiste. Im Dreisekundentakt eines
// laufenden Scans heisst das: sie verschwindet sichtbar und kommt wieder,
// der Mauszeiger steht plötzlich über einem anderen Eintrag, und ein
// gerade gezogener Eintrag wird mitten in der Bewegung weggerissen.
const gleich = frisch.length === mediatheken.length
&& frisch.every((b, i) => b.id === mediatheken[i]!.id && b.name === mediatheken[i]!.name);
mediatheken = frisch;
if (!gleich) { leisteBauen(); return; }
frisch.forEach(b => {
const zeile = leiste.querySelector<HTMLElement>(`[data-ziel="#/m/${b.id}"] .zahl`);
if (zeile) zeile.textContent = String(b.itemCount ?? '');
});
} }
/* ---------- Kopfleiste ---------- */ /* ---------- Kopfleiste ---------- */
@@ -373,11 +397,14 @@ function zeichnen(): void {
} else if (teile[0] === 'verwaltung' && istAdmin()) { } else if (teile[0] === 'verwaltung' && istAdmin()) {
// **Die Verwaltung auch nicht** — sie zeigt Laufendes. Ihr Takt hängt am // **Die Verwaltung auch nicht** — sie zeigt Laufendes. Ihr Takt hängt am
// Signal und hört auf, sobald man weggeht. // Signal und hört auf, sobald man weggeht.
einhaengen(weg, inhalt, signal => verwaltung(() => { einhaengen(weg, inhalt, signal => verwaltung(bekannt => {
// Gemerkte Ansichten wegwerfen: der Bestand hat sich geändert. // Gemerkte Ansichten wegwerfen: der Bestand hat sich geändert.
// Die Seitenleiste bekommt neue Zahlen — die Seite selbst bleibt. // Die Seitenleiste bekommt neue Zahlen — die Seite selbst bleibt.
vergessen(); vergessen();
void zaehlerAuffrischen(); // `bekannt` ist der Stand, den die Verwaltung gerade geholt hat. Ihn
// weiterzureichen heisst: beide zeigen dieselbe Zahl, nicht nur
// ähnliche.
void zaehlerAuffrischen(bekannt);
}, signal)); }, signal));
} else { } else {
einhaengen(weg, inhalt, () => startseite(mediatheken, oeffnen, alleZeigen), einhaengen(weg, inhalt, () => startseite(mediatheken, oeffnen, alleZeigen),
+128 -49
View File
@@ -26,7 +26,7 @@ import { icon } from '../icons';
* `geaendert` wird gerufen, wenn sich die Mediathekenliste geändert hat; die * `geaendert` wird gerufen, wenn sich die Mediathekenliste geändert hat; die
* Seitenleiste steht auf ihr und muss dann neu gezeichnet werden. */ * Seitenleiste steht auf ihr und muss dann neu gezeichnet werden. */
export function verwaltung( export function verwaltung(
geaendert: () => void = () => {}, geaendert: (bekannt?: Bibliothek[]) => void = () => {},
signal: AbortSignal = new AbortController().signal, signal: AbortSignal = new AbortController().signal,
): HTMLElement { ): HTMLElement {
const wurzel = document.createElement('div'); const wurzel = document.createElement('div');
@@ -655,7 +655,15 @@ function vermisstenbereich(geaendert: () => void): HTMLElement {
/* ---------- Bibliotheken ---------- */ /* ---------- Bibliotheken ---------- */
function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLElement { /** Eine gezeichnete Mediathekszeile und der Weg, sie fortzuschreiben. */
type Mediathekzeile = {
wurzel: HTMLElement;
aktualisieren: (b: Bibliothek) => void;
};
function bibliotheksbereich(
geaendert: (bekannt?: Bibliothek[]) => void, signal: AbortSignal,
): HTMLElement {
const bereich = document.createElement('section'); const bereich = document.createElement('section');
@@ -695,49 +703,100 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle
const liste = document.createElement('div'); const liste = document.createElement('div');
liste.className = 'einstellungen'; liste.className = 'einstellungen';
bereich.appendChild(liste); bereich.appendChild(liste);
/** Die gezeichneten Zeilen, je Mediathek.
*
* **Vor dem ersten `laden()`.** Stand die Map darunter, lief der erste
* Aufruf in die temporale Totzone von `const` — „Cannot access 'zeilen'
* before initialization", und der ganze Abschnitt blieb leer.
*/
const zeilen = new Map<string, Mediathekzeile>();
void laden(); void laden();
async function laden(): Promise<void> { /** **Auffrischen heisst fortschreiben, nicht neu zeichnen.**
// **Der Spinner gehört an den ersten Aufbau, nicht an jedes Auffrischen.** *
// Sonst leert sich die Liste sichtbar, bevor dasselbe wieder erscheint — * Hier stand `liste.innerHTML = ''` und danach ein Neuaufbau — alle drei
// und bei einem Takt im Hintergrund blinkt sie dauernd. * Sekunden, während ein Scan läuft. Der ganze Abschnitt verschwand dabei
if (liste.childElementCount === 0) liste.innerHTML = '<div class="spinner"></div>'; * sichtbar und kam wieder; wer gerade zielte, klickte ins Leere.
try { *
const alle = await bibliotheken(); * Schlimmer noch: jeder Neuaufbau startete über `verfolgen(false)` einen
liste.innerHTML = ''; * weiteren Takt. Nach einer Minute liefen zwanzig davon nebeneinander und
if (alle.length === 0) { * fragten im Sekundentakt denselben Scanstand ab.
liste.appendChild(leer('Noch keine Mediathek. Lege oben eine an.')); */
return; async function laden(): Promise<Bibliothek[] | undefined> {
} if (zeilen.size === 0 && liste.childElementCount === 0) {
alle.forEach(b => liste.appendChild(block(b))); liste.innerHTML = '<div class="spinner"></div>';
} catch (fehler) {
liste.innerHTML = '';
liste.appendChild(fehlerzeile(meldung(fehler)));
}
} }
function block(b: Bibliothek): HTMLElement { let alle: Bibliothek[];
try { alle = await bibliotheken(); }
catch (fehler) {
// Beim Auffrischen bleibt stehen, was da ist: ein Aussetzer darf keine
// Liste wegräumen, die noch stimmt.
if (zeilen.size === 0) { liste.innerHTML = ''; liste.appendChild(fehlerzeile(meldung(fehler))); }
return undefined;
}
if (alle.length === 0) {
zeilen.clear();
liste.innerHTML = '';
liste.appendChild(leer('Noch keine Mediathek. Lege oben eine an.'));
return alle;
}
// Spinner oder Hinweis weg — aber nur beim allerersten Mal.
if (zeilen.size === 0) liste.innerHTML = '';
// Was es nicht mehr gibt, verschwindet.
[...zeilen.keys()]
.filter(id => !alle.some(b => b.id === id))
.forEach(id => { zeilen.get(id)!.wurzel.remove(); zeilen.delete(id); });
alle.forEach((b, i) => {
let zeile = zeilen.get(b.id);
if (zeile) zeile.aktualisieren(b);
else { zeile = block(b); zeilen.set(b.id, zeile); }
// Reihenfolge angleichen, ohne alles anzufassen: `insertBefore` mit
// einem Knoten, der schon dort steht, tut nichts.
if (liste.children[i] !== zeile.wurzel) {
liste.insertBefore(zeile.wurzel, liste.children[i] ?? null);
}
});
return alle;
}
function block(b: Bibliothek): Mediathekzeile {
// Die Knöpfe lesen den Stand über diese Grösse, nicht über `b` — sonst
// arbeiteten sie nach dem ersten Auffrischen mit veralteten Werten.
let aktuell = b;
const d = document.createElement('div'); const d = document.createElement('div');
d.className = 'einstellung'; d.className = 'einstellung';
const serie = b.kind === 'Shows'; const kopf = document.createElement('div');
kopf.className = 'zeile';
d.appendChild(kopf);
function kopfSchreiben(): void {
const serie = aktuell.kind === 'Shows';
// „1 Filme" ist der Satz, an dem man sieht, dass niemand hingeschaut hat. // „1 Filme" ist der Satz, an dem man sieht, dass niemand hingeschaut hat.
const anzahl = b.itemCount == null ? '' const anzahl = aktuell.itemCount == null ? ''
: ` · ${b.itemCount} ` + (b.itemCount === 1 : ` · ${aktuell.itemCount} ` + (aktuell.itemCount === 1
? (serie ? 'Serie' : 'Film') ? (serie ? 'Serie' : 'Film')
: (serie ? 'Serien' : 'Filme')); : (serie ? 'Serien' : 'Filme'));
// Was wartet, gehört in dieselbe Zeile — sonst erfährt man es nur, wenn // Was wartet, gehört in dieselbe Zeile — sonst erfährt man es nur, wenn
// man bis zur Prüfliste weiterscrollt. // man bis zur Prüfliste weiterscrollt.
const wartet = b.pendingCount > 0 const wartet = aktuell.pendingCount > 0
? ` · ${b.pendingCount} wartet${b.pendingCount === 1 ? '' : 'en'} auf Freigabe` ? ` · ${aktuell.pendingCount} wartet${aktuell.pendingCount === 1 ? '' : 'en'} auf Freigabe`
: ''; : '';
const kopf = document.createElement('div'); kopf.innerHTML = `<strong>${esc(aktuell.name)}</strong>`
kopf.className = 'zeile'; + `<span class="winzig">${esc(aktuell.path)}${esc(anzahl)}${esc(wartet)}</span>`;
kopf.innerHTML = `<strong>${esc(b.name)}</strong>` }
+ `<span class="winzig">${esc(b.path)}${esc(anzahl)}${esc(wartet)}</span>`; kopfSchreiben();
d.appendChild(kopf);
const stand = document.createElement('div'); const stand = document.createElement('div');
stand.className = 'hinweis'; stand.className = 'hinweis';
@@ -745,7 +804,7 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle
const los = knopf('Scannen', 'btn'); const los = knopf('Scannen', 'btn');
los.onclick = async () => { los.onclick = async () => {
laufendSetzen(true); laufendSetzen(true);
try { await scanStarten(b.id); await verfolgen(true); } try { await scanStarten(aktuell.id); await verfolgen(true); }
catch (fehler) { stand.textContent = meldung(fehler); laufendSetzen(false); } catch (fehler) { stand.textContent = meldung(fehler); laufendSetzen(false); }
}; };
@@ -777,14 +836,14 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle
auffrischen.title = 'Holt Titel, Bilder und Besetzung erneut — ' auffrischen.title = 'Holt Titel, Bilder und Besetzung erneut — '
+ 'nötig nach einer Sprachänderung.'; + 'nötig nach einer Sprachänderung.';
auffrischen.onclick = () => nachfragen({ auffrischen.onclick = () => nachfragen({
titel: `Metadaten von „${b.name}" auffrischen?`, titel: `Metadaten von „${aktuell.name}" auffrischen?`,
text: 'Alle Titel werden erneut bei TMDB geholt — über die bekannte Kennung, ' text: 'Alle Titel werden erneut bei TMDB geholt — über die bekannte Kennung, '
+ 'nicht über eine neue Suche. Zuordnungen von Hand bleiben damit stehen. ' + 'nicht über eine neue Suche. Zuordnungen von Hand bleiben damit stehen. '
+ 'Bei vielen Titeln dauert es.', + 'Bei vielen Titeln dauert es.',
bestaetigen: 'Auffrischen', bestaetigen: 'Auffrischen',
tun: async () => { tun: async () => {
laufendSetzen(true); laufendSetzen(true);
await metadatenAuffrischen(b.id); await metadatenAuffrischen(aktuell.id);
void verfolgen(true); void verfolgen(true);
}, },
}); });
@@ -792,10 +851,10 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle
const umbenennen = knopf('Umbenennen', 'btn'); const umbenennen = knopf('Umbenennen', 'btn');
umbenennen.onclick = () => dialog({ umbenennen.onclick = () => dialog({
titel: 'Mediathek umbenennen', titel: 'Mediathek umbenennen',
felder: [{ name: 'name', beschriftung: 'Name', wert: b.name }], felder: [{ name: 'name', beschriftung: 'Name', wert: aktuell.name }],
bestaetigen: 'Speichern', bestaetigen: 'Speichern',
tun: async wert => { tun: async wert => {
await bibliothekUmbenennen(b.id, wert('name')); await bibliothekUmbenennen(aktuell.id, wert('name'));
await laden(); await laden();
geaendert(); geaendert();
}, },
@@ -807,14 +866,14 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle
// einmal; genau das war bisher der einzige Weg. // einmal; genau das war bisher der einzige Weg.
const zuruecksetzen = knopf('Zurücksetzen', 'btn'); const zuruecksetzen = knopf('Zurücksetzen', 'btn');
zuruecksetzen.onclick = () => nachfragen({ zuruecksetzen.onclick = () => nachfragen({
titel: `„${b.name}" zurücksetzen?`, titel: `„${aktuell.name}" zurücksetzen?`,
text: 'Alle erfassten Titel, Zuordnungen und der Sehfortschritt dieser ' text: 'Alle erfassten Titel, Zuordnungen und der Sehfortschritt dieser '
+ 'Mediathek werden verworfen; Name, Pfad und Einstellungen bleiben. ' + 'Mediathek werden verworfen; Name, Pfad und Einstellungen bleiben. '
+ 'Die Mediendateien auf der Platte bleiben unangetastet. Danach einmal ' + 'Die Mediendateien auf der Platte bleiben unangetastet. Danach einmal '
+ 'scannen, und der Bestand entsteht neu.', + 'scannen, und der Bestand entsteht neu.',
bestaetigen: 'Zurücksetzen', bestaetigen: 'Zurücksetzen',
tun: async () => { tun: async () => {
await mediathekZuruecksetzen(b.id); await mediathekZuruecksetzen(aktuell.id);
await laden(); await laden();
geaendert(); geaendert();
}, },
@@ -822,11 +881,11 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle
const entfernen = knopf('Entfernen', 'btn'); const entfernen = knopf('Entfernen', 'btn');
entfernen.onclick = () => nachfragen({ entfernen.onclick = () => nachfragen({
titel: `„${b.name}" entfernen?`, titel: `„${aktuell.name}" entfernen?`,
text: 'Die Mediendateien auf der Platte bleiben unangetastet. Weg sind die ' text: 'Die Mediendateien auf der Platte bleiben unangetastet. Weg sind die '
+ 'Einträge, die Zuordnungen und der Sehfortschritt aller Benutzer dazu.', + 'Einträge, die Zuordnungen und der Sehfortschritt aller Benutzer dazu.',
bestaetigen: 'Entfernen', bestaetigen: 'Entfernen',
tun: async () => { await bibliothekLoeschen(b.id); await laden(); geaendert(); }, tun: async () => { await bibliothekLoeschen(aktuell.id); await laden(); geaendert(); },
}); });
/** Fragt nach, bis der Lauf durch ist — er läuft im Hintergrund. */ /** Fragt nach, bis der Lauf durch ist — er läuft im Hintergrund. */
@@ -840,7 +899,7 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle
async function verfolgen(melden: boolean): Promise<void> { async function verfolgen(melden: boolean): Promise<void> {
for (let i = 0; i < 900; i++) { for (let i = 0; i < 900; i++) {
if (signal.aborted) return; if (signal.aborted) return;
const s = await scanstand(b.id); const s = await scanstand(aktuell.id);
// **Was der Server sagt, nicht was wir raten.** Vorher stand hier // **Was der Server sagt, nicht was wir raten.** Vorher stand hier
// minutenlang „Wird durchsucht…", ohne Hinweis, ob etwas passiert. // minutenlang „Wird durchsucht…", ohne Hinweis, ob etwas passiert.
// **Die Sperre richtet sich nach dem Server, nicht nach dem Klick.** // **Die Sperre richtet sich nach dem Server, nicht nach dem Klick.**
@@ -869,7 +928,7 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle
// //
// Alle drei Runden statt alle fünf: nah genug am Zähler in der // Alle drei Runden statt alle fünf: nah genug am Zähler in der
// Mediathek, ohne je Sekunde zwei Abrufe zu kosten. // Mediathek, ohne je Sekunde zwei Abrufe zu kosten.
if (melden && i > 0 && i % 3 === 0) { await laden(); geaendert(); } if (melden && i > 0 && i % 3 === 0) geaendert(await laden());
if (s.state === 'finished') { if (s.state === 'finished') {
const teile = [`${s.filesFound} Dateien`, `${s.added} neu`, `${s.removed} entfernt`]; const teile = [`${s.filesFound} Dateien`, `${s.added} neu`, `${s.removed} entfernt`];
@@ -895,11 +954,11 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle
// Die Prüfung lässt sich je Mediathek umstellen — was beim Anlegen // Die Prüfung lässt sich je Mediathek umstellen — was beim Anlegen
// entschieden wurde, soll kein Urteil fürs Leben sein. // entschieden wurde, soll kein Urteil fürs Leben sein.
const pruefung = knopf(b.reviewNewItems ? 'Prüfung an' : 'Prüfung aus', 'btn'); const pruefung = knopf(aktuell.reviewNewItems ? 'Prüfung an' : 'Prüfung aus', 'btn');
pruefung.onclick = async () => { pruefung.onclick = async () => {
pruefung.disabled = true; pruefung.disabled = true;
try { try {
await pruefungSetzen(b.id, !b.reviewNewItems); await pruefungSetzen(aktuell.id, !aktuell.reviewNewItems);
await laden(); await laden();
} catch (fehler) { } catch (fehler) {
d.appendChild(fehlerzeile(meldung(fehler))); d.appendChild(fehlerzeile(meldung(fehler)));
@@ -919,27 +978,47 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle
// **Nur wenn hier wirklich etwas liegt.** Ein Knopf, der nichts zu tun // **Nur wenn hier wirklich etwas liegt.** Ein Knopf, der nichts zu tun
// hat, ist schlechter als keiner — und die Zahl daneben sagt, worum es // hat, ist schlechter als keiner — und die Zahl daneben sagt, worum es
// geht, ohne dass man erst weiterscrollen muss. // geht, ohne dass man erst weiterscrollen muss.
if (b.pendingCount > 0) { //
const freigeben = knopf( // Angelegt wird er trotzdem immer und nur versteckt: ihn beim Auffrischen
`${b.pendingCount} ${b.pendingCount === 1 ? 'Titel' : 'Titel'} freigeben`, // anzulegen und wieder wegzuwerfen wäre genau das Flackern, das hier
'btn btn-primary'); // gerade abgestellt wird.
const freigeben = knopf('', 'btn btn-primary');
freigeben.onclick = async () => { freigeben.onclick = async () => {
freigeben.disabled = true; freigeben.disabled = true;
try { try {
const bericht = await mediathekFreigeben(b.id); const bericht = await mediathekFreigeben(aktuell.id);
stand.textContent = `${bericht.published} freigegeben.`; stand.textContent = `${bericht.published} freigegeben.`;
await laden(); await laden();
geaendert(); geaendert();
} catch (fehler) { } catch (fehler) {
d.appendChild(fehlerzeile(meldung(fehler))); d.appendChild(fehlerzeile(meldung(fehler)));
} finally {
freigeben.disabled = false; freigeben.disabled = false;
} }
}; };
knoepfe.appendChild(freigeben); knoepfe.appendChild(freigeben);
function freigabeSchreiben(): void {
freigeben.hidden = aktuell.pendingCount === 0;
freigeben.textContent = `${aktuell.pendingCount} Titel freigeben`;
} }
freigabeSchreiben();
d.append(knoepfe, stand); d.append(knoepfe, stand);
// **Genau ein Takt je Mediathek.** Er entsteht hier, beim Anlegen der
// Zeile — und weil die Zeile nicht mehr neu gebaut wird, bleibt es dabei.
void verfolgen(false); void verfolgen(false);
return d;
return {
wurzel: d,
aktualisieren: (neu: Bibliothek) => {
aktuell = neu;
kopfSchreiben();
pruefung.textContent = aktuell.reviewNewItems ? 'Prüfung an' : 'Prüfung aus';
freigabeSchreiben();
},
};
} }
return bereich; return bereich;