diff --git a/Flaro/AppIcon.icon/Assets/flare (1).svg b/Flaro/AppIcon.icon/Assets/flare (1).svg new file mode 100644 index 0000000..ae42cbc --- /dev/null +++ b/Flaro/AppIcon.icon/Assets/flare (1).svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Flaro/AppIcon.icon/Assets/play.svg b/Flaro/AppIcon.icon/Assets/play.svg deleted file mode 100644 index e2aaae1..0000000 --- a/Flaro/AppIcon.icon/Assets/play.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/Flaro/AppIcon.icon/icon.json b/Flaro/AppIcon.icon/icon.json index 6d8fa01..2208296 100644 --- a/Flaro/AppIcon.icon/icon.json +++ b/Flaro/AppIcon.icon/icon.json @@ -2,7 +2,7 @@ "fill-specializations" : [ { "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" : [ { "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", - "name" : "play", + "image-name" : "flare (1).svg", + "name" : "flare (1)", "position" : { - "scale" : 1.18, + "scale" : 100, "translation-in-points" : [ - 49.7265625, - 1.468530916781674e-05 + 0, + 0 ] } } diff --git a/server/src/Flaro.Api/Endpoints/ManagementEndpoints.cs b/server/src/Flaro.Api/Endpoints/ManagementEndpoints.cs index 4210b8b..f113f9c 100644 --- a/server/src/Flaro.Api/Endpoints/ManagementEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/ManagementEndpoints.cs @@ -282,11 +282,22 @@ public static class ManagementEndpoints // 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) => + bibliotheken.MapDelete("/{id:guid}", async ( + Guid id, FlaroContext db, ScanCoordinator koordinator) => { var vorhanden = await db.Libraries.FindAsync(id); 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); await db.SaveChangesAsync(); return Results.NoContent(); @@ -302,11 +313,27 @@ public static class ManagementEndpoints // // Nicht gelöscht wird eine einzige Mediendatei. Entfernt wird allein, // 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); 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 // Fremdschlüssel zeigen von den Dateien auf Film und Folge. await db.MediaFiles.Where(f => f.LibraryId == id).ExecuteDeleteAsync(); diff --git a/server/src/Flaro.Api/Fehlertext.cs b/server/src/Flaro.Api/Fehlertext.cs index cc308f3..461af10 100644 --- a/server/src/Flaro.Api/Fehlertext.cs +++ b/server/src/Flaro.Api/Fehlertext.cs @@ -57,6 +57,11 @@ public static partial class Fehlertext + "Oft ist das ein volles Volume oder ein schlafendes Laufwerk.", TaskCanceledException or OperationCanceledException => "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 => "Die Metadatenquelle war nicht erreichbar. " + "Beim nächsten Lauf wird es erneut versucht.", diff --git a/server/src/Flaro.Data/MetadataEnricher.cs b/server/src/Flaro.Data/MetadataEnricher.cs index 936678e..4054a8e 100644 --- a/server/src/Flaro.Data/MetadataEnricher.cs +++ b/server/src/Flaro.Data/MetadataEnricher.cs @@ -325,9 +325,12 @@ public sealed class MetadataEnricher( private async Task FolgenZuordnen( Guid libraryId, bool erneut, IProgress? 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 .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); if (serien.Count == 0) return 0; @@ -336,16 +339,21 @@ public sealed class MetadataEnricher( .Where(f => kennungen.Contains(f.SeriesId) && (erneut || f.Overview == null)) .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 durch = 0; foreach (var folge in offen) { + ct.ThrowIfCancellationRequested(); + if (!nachKennung.TryGetValue(folge.SeriesId, out var serie)) continue; + fortschritt?.Report(new Scanschritt( Scanphase.Holen, - $"Folge {folge.SeasonNumber}x{folge.EpisodeNumber:00}", ++durch, offen.Count)); - - ct.ThrowIfCancellationRequested(); - var serie = serien.First(s => s.Id == folge.SeriesId); + $"{serie.Title} {folge.SeasonNumber}x{folge.EpisodeNumber:00}", + ++durch, offen.Count)); var gefunden = await anbieter.FindEpisodeAsync(serie.TmdbId, folge.SeasonNumber, folge.EpisodeNumber, ct); if (gefunden is null) continue; diff --git a/server/tests/Flaro.Api.Tests/ScanTests.cs b/server/tests/Flaro.Api.Tests/ScanTests.cs index 85d7290..55bce13 100644 --- a/server/tests/Flaro.Api.Tests/ScanTests.cs +++ b/server/tests/Flaro.Api.Tests/ScanTests.cs @@ -500,6 +500,32 @@ public class ScanTests Assert.That(offen.Dateien[0].Grund, Does.Contain("keine Folgennummer")); } +/// Zurücksetzen und Entfernen prallen ab, solange ein Lauf läuft. +/// +/// +/// Die Oberfläche sperrt die Knöpfe, aber darauf darf sich der Server nicht +/// verlassen. 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. +/// +[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] public async Task Die_Suche_grenzt_ein() { diff --git a/web/app/index.html b/web/app/index.html index 5f2c4d2..b1ee8aa 100644 --- a/web/app/index.html +++ b/web/app/index.html @@ -5,6 +5,19 @@ Flaro + + + + + + diff --git a/web/app/public/favicon.svg b/web/app/public/favicon.svg new file mode 100644 index 0000000..8736a89 --- /dev/null +++ b/web/app/public/favicon.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + diff --git a/web/app/public/icon-180.png b/web/app/public/icon-180.png new file mode 100644 index 0000000..4de6600 Binary files /dev/null and b/web/app/public/icon-180.png differ diff --git a/web/app/public/icon-192.png b/web/app/public/icon-192.png new file mode 100644 index 0000000..3b39a54 Binary files /dev/null and b/web/app/public/icon-192.png differ diff --git a/web/app/public/icon-32.png b/web/app/public/icon-32.png new file mode 100644 index 0000000..56d3bf0 Binary files /dev/null and b/web/app/public/icon-32.png differ diff --git a/web/app/public/icon-512.png b/web/app/public/icon-512.png new file mode 100644 index 0000000..04d96a0 Binary files /dev/null and b/web/app/public/icon-512.png differ diff --git a/web/app/public/manifest.webmanifest b/web/app/public/manifest.webmanifest new file mode 100644 index 0000000..81cfc6d --- /dev/null +++ b/web/app/public/manifest.webmanifest @@ -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" } + ] +} diff --git a/web/app/src/main.ts b/web/app/src/main.ts index 5326499..c899c5c 100644 --- a/web/app/src/main.ts +++ b/web/app/src/main.ts @@ -86,9 +86,33 @@ export async function mediathekenNeuLaden(): Promise { * Endlosschleife mit zwei Abrufen je Runde, die sich als vier dauernd * drehende Spinner zeigte. Ein neuer Zählwert in der Seitenleiste braucht * keinen Neuaufbau der Seite. */ -export async function zaehlerAuffrischen(): Promise { - mediatheken = await bibliotheken(); - leisteBauen(); +export async function zaehlerAuffrischen(bekannt?: Bibliothek[]): Promise { + // **Denselben Stand zeigen, nicht denselben Takt haben.** + // + // 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(`[data-ziel="#/m/${b.id}"] .zahl`); + if (zeile) zeile.textContent = String(b.itemCount ?? ''); + }); } /* ---------- Kopfleiste ---------- */ @@ -373,11 +397,14 @@ function zeichnen(): void { } else if (teile[0] === 'verwaltung' && istAdmin()) { // **Die Verwaltung auch nicht** — sie zeigt Laufendes. Ihr Takt hängt am // 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. // Die Seitenleiste bekommt neue Zahlen — die Seite selbst bleibt. 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)); } else { einhaengen(weg, inhalt, () => startseite(mediatheken, oeffnen, alleZeigen), diff --git a/web/app/src/views/admin.ts b/web/app/src/views/admin.ts index dae8a11..37a7612 100644 --- a/web/app/src/views/admin.ts +++ b/web/app/src/views/admin.ts @@ -26,7 +26,7 @@ import { icon } from '../icons'; * `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 = () => {}, + geaendert: (bekannt?: Bibliothek[]) => void = () => {}, signal: AbortSignal = new AbortController().signal, ): HTMLElement { const wurzel = document.createElement('div'); @@ -655,7 +655,15 @@ function vermisstenbereich(geaendert: () => void): HTMLElement { /* ---------- 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'); @@ -695,57 +703,108 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle const liste = document.createElement('div'); liste.className = 'einstellungen'; 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(); + void laden(); - async function laden(): Promise { - // **Der Spinner gehört an den ersten Aufbau, nicht an jedes Auffrischen.** - // Sonst leert sich die Liste sichtbar, bevor dasselbe wieder erscheint — - // und bei einem Takt im Hintergrund blinkt sie dauernd. - if (liste.childElementCount === 0) liste.innerHTML = '
'; - try { - const alle = await bibliotheken(); - liste.innerHTML = ''; - if (alle.length === 0) { - liste.appendChild(leer('Noch keine Mediathek. Lege oben eine an.')); - return; - } - alle.forEach(b => liste.appendChild(block(b))); - } catch (fehler) { - liste.innerHTML = ''; - liste.appendChild(fehlerzeile(meldung(fehler))); + /** **Auffrischen heisst fortschreiben, nicht neu zeichnen.** + * + * Hier stand `liste.innerHTML = ''` und danach ein Neuaufbau — alle drei + * Sekunden, während ein Scan läuft. Der ganze Abschnitt verschwand dabei + * sichtbar und kam wieder; wer gerade zielte, klickte ins Leere. + * + * Schlimmer noch: jeder Neuaufbau startete über `verfolgen(false)` einen + * weiteren Takt. Nach einer Minute liefen zwanzig davon nebeneinander und + * fragten im Sekundentakt denselben Scanstand ab. + */ + async function laden(): Promise { + if (zeilen.size === 0 && liste.childElementCount === 0) { + liste.innerHTML = '
'; } + + 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): HTMLElement { + 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'); d.className = 'einstellung'; - const serie = b.kind === 'Shows'; - // „1 Filme" ist der Satz, an dem man sieht, dass niemand hingeschaut hat. - const anzahl = b.itemCount == null ? '' - : ` · ${b.itemCount} ` + (b.itemCount === 1 - ? (serie ? 'Serie' : 'Film') - : (serie ? 'Serien' : 'Filme')); - - // Was wartet, gehört in dieselbe Zeile — sonst erfährt man es nur, wenn - // man bis zur Prüfliste weiterscrollt. - const wartet = b.pendingCount > 0 - ? ` · ${b.pendingCount} wartet${b.pendingCount === 1 ? '' : 'en'} auf Freigabe` - : ''; - const kopf = document.createElement('div'); kopf.className = 'zeile'; - kopf.innerHTML = `${esc(b.name)}` - + `${esc(b.path)}${esc(anzahl)}${esc(wartet)}`; d.appendChild(kopf); + function kopfSchreiben(): void { + const serie = aktuell.kind === 'Shows'; + // „1 Filme" ist der Satz, an dem man sieht, dass niemand hingeschaut hat. + const anzahl = aktuell.itemCount == null ? '' + : ` · ${aktuell.itemCount} ` + (aktuell.itemCount === 1 + ? (serie ? 'Serie' : 'Film') + : (serie ? 'Serien' : 'Filme')); + + // Was wartet, gehört in dieselbe Zeile — sonst erfährt man es nur, wenn + // man bis zur Prüfliste weiterscrollt. + const wartet = aktuell.pendingCount > 0 + ? ` · ${aktuell.pendingCount} wartet${aktuell.pendingCount === 1 ? '' : 'en'} auf Freigabe` + : ''; + + kopf.innerHTML = `${esc(aktuell.name)}` + + `${esc(aktuell.path)}${esc(anzahl)}${esc(wartet)}`; + } + kopfSchreiben(); + const stand = document.createElement('div'); stand.className = 'hinweis'; const los = knopf('Scannen', 'btn'); los.onclick = async () => { 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); } }; @@ -777,14 +836,14 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle auffrischen.title = 'Holt Titel, Bilder und Besetzung erneut — ' + 'nötig nach einer Sprachänderung.'; 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, ' + 'nicht über eine neue Suche. Zuordnungen von Hand bleiben damit stehen. ' + 'Bei vielen Titeln dauert es.', bestaetigen: 'Auffrischen', tun: async () => { laufendSetzen(true); - await metadatenAuffrischen(b.id); + await metadatenAuffrischen(aktuell.id); void verfolgen(true); }, }); @@ -792,10 +851,10 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle const umbenennen = knopf('Umbenennen', 'btn'); umbenennen.onclick = () => dialog({ titel: 'Mediathek umbenennen', - felder: [{ name: 'name', beschriftung: 'Name', wert: b.name }], + felder: [{ name: 'name', beschriftung: 'Name', wert: aktuell.name }], bestaetigen: 'Speichern', tun: async wert => { - await bibliothekUmbenennen(b.id, wert('name')); + await bibliothekUmbenennen(aktuell.id, wert('name')); await laden(); geaendert(); }, @@ -807,14 +866,14 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle // einmal; genau das war bisher der einzige Weg. const zuruecksetzen = knopf('Zurücksetzen', 'btn'); zuruecksetzen.onclick = () => nachfragen({ - titel: `„${b.name}" zurücksetzen?`, + titel: `„${aktuell.name}" zurücksetzen?`, text: 'Alle erfassten Titel, Zuordnungen und der Sehfortschritt dieser ' + 'Mediathek werden verworfen; Name, Pfad und Einstellungen bleiben. ' + 'Die Mediendateien auf der Platte bleiben unangetastet. Danach einmal ' + 'scannen, und der Bestand entsteht neu.', bestaetigen: 'Zurücksetzen', tun: async () => { - await mediathekZuruecksetzen(b.id); + await mediathekZuruecksetzen(aktuell.id); await laden(); geaendert(); }, @@ -822,11 +881,11 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle const entfernen = knopf('Entfernen', 'btn'); entfernen.onclick = () => nachfragen({ - titel: `„${b.name}" entfernen?`, + titel: `„${aktuell.name}" entfernen?`, text: 'Die Mediendateien auf der Platte bleiben unangetastet. Weg sind die ' + 'Einträge, die Zuordnungen und der Sehfortschritt aller Benutzer dazu.', 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. */ @@ -840,7 +899,7 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle async function verfolgen(melden: boolean): Promise { for (let i = 0; i < 900; i++) { 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 // minutenlang „Wird durchsucht…", ohne Hinweis, ob etwas passiert. // **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 // 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') { 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 // 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.disabled = true; try { - await pruefungSetzen(b.id, !b.reviewNewItems); + await pruefungSetzen(aktuell.id, !aktuell.reviewNewItems); await laden(); } catch (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 // hat, ist schlechter als keiner — und die Zahl daneben sagt, worum es // geht, ohne dass man erst weiterscrollen muss. - if (b.pendingCount > 0) { - const freigeben = knopf( - `${b.pendingCount} ${b.pendingCount === 1 ? 'Titel' : 'Titel'} freigeben`, - 'btn btn-primary'); - freigeben.onclick = async () => { - freigeben.disabled = true; - try { - const bericht = await mediathekFreigeben(b.id); - stand.textContent = `${bericht.published} freigegeben.`; - await laden(); - geaendert(); - } catch (fehler) { - d.appendChild(fehlerzeile(meldung(fehler))); - freigeben.disabled = false; - } - }; - knoepfe.appendChild(freigeben); + // + // Angelegt wird er trotzdem immer und nur versteckt: ihn beim Auffrischen + // anzulegen und wieder wegzuwerfen wäre genau das Flackern, das hier + // gerade abgestellt wird. + const freigeben = knopf('', 'btn btn-primary'); + freigeben.onclick = async () => { + freigeben.disabled = true; + try { + const bericht = await mediathekFreigeben(aktuell.id); + stand.textContent = `${bericht.published} freigegeben.`; + await laden(); + geaendert(); + } catch (fehler) { + d.appendChild(fehlerzeile(meldung(fehler))); + } finally { + freigeben.disabled = false; + } + }; + knoepfe.appendChild(freigeben); + + function freigabeSchreiben(): void { + freigeben.hidden = aktuell.pendingCount === 0; + freigeben.textContent = `${aktuell.pendingCount} Titel freigeben`; } + freigabeSchreiben(); + 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); - return d; + + return { + wurzel: d, + aktualisieren: (neu: Bibliothek) => { + aktuell = neu; + kopfSchreiben(); + pruefung.textContent = aktuell.reviewNewItems ? 'Prüfung an' : 'Prüfung aus'; + freigabeSchreiben(); + }, + }; } return bereich;