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;