From a22fa9b58e3c4796e1c35278a242d3a6e7008e6e Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sun, 4 Oct 2026 16:31:50 +0200 Subject: [PATCH] =?UTF-8?q?Drei=20Z=C3=A4hler=20f=C3=BCr=20dieselbe=20Sach?= =?UTF-8?q?e,=20und=20keiner=20davon=20offensichtlich=20falsch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Während eines Scans stand in der Mediathek 20, in der Verwaltung 14 und in der Seitenleiste 0. Die Verwaltung frischte alle fünf Runden auf, die Seitenleiste erst am Ende des Laufs. Jetzt laufen beide auf demselben Takt, alle drei Runden. Dazu: während ein Lauf läuft, sind Scannen, Metadaten auffrischen, Zurücksetzen und Entfernen gesperrt — die ersten beiden prallen am Server ab und sind nur verwirrend, die anderen beiden löschen Zeilen, während der Lauf welche schreibt. Umbenennen und Prüfung bleiben bedienbar. Die Sperre richtet sich nach dem Zustand, den der Server meldet, nicht nach dem Klick: wer die Seite neu lädt, während ein Lauf läuft, findet sie ebenfalls gesperrt vor. Die Reihenfolge der Mediatheken wird jetzt in der Seitenleiste umsortiert, über „Seitenleiste bearbeiten" neben der Überschrift. Sie wirkt dort, also gehört der Griff dorthin — vorher schob man in den Einstellungen und musste danach nachsehen, ob es das war, was man wollte. Der Zuordnungsdialog zeigt die Herkunft: Ordner, Zahl der Dateien und ein paar Namen. Liegt eine Zuordnung völlig daneben, sagt der angezeigte Titel nichts mehr darüber, welche Datei gemeint ist. Und SQLite läuft jetzt im WAL-Modus. In der Vorgabe sperrt ein Schreiber die ganze Datei: solange der Scan schrieb, kam keine Leseanfrage durch. Genau so sah es aus — Bilddialog und Protokoll liefen während eines Laufs sporadisch ins Leere. Co-Authored-By: Claude Opus 5 --- .../src/Flaro.Api/Endpoints/MatchEndpoints.cs | 74 ++++++++++ server/src/Flaro.Api/Program.cs | 33 ++++- web/app/src/api.ts | 6 + web/app/src/main.ts | 101 ++++++++++++-- web/app/src/style.css | 39 ++++++ web/app/src/views/admin.ts | 126 ++++++++++-------- web/app/src/zuordnung.ts | 34 ++++- 7 files changed, 348 insertions(+), 65 deletions(-) diff --git a/server/src/Flaro.Api/Endpoints/MatchEndpoints.cs b/server/src/Flaro.Api/Endpoints/MatchEndpoints.cs index ac5de8b..78664b3 100644 --- a/server/src/Flaro.Api/Endpoints/MatchEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/MatchEndpoints.cs @@ -25,6 +25,48 @@ public static class MatchEndpoints { public static void MapMatching(this IEndpointRouteBuilder app) { + // **Woher dieser Titel kommt.** + // + // Steht im Dialog über der Suche. Wenn eine Zuordnung völlig daneben + // liegt — „Dark Shadows" von 1966 statt „Dark" —, sagt der angezeigte + // Titel nichts mehr darüber, welche Datei da eigentlich gemeint ist. + // Der Ordner sagt es, und zwar sofort. + app.MapGet("/api/v1/{art:regex(^movies|series$)}/{id:guid}/herkunft", async ( + string art, Guid id, FlaroContext db) => + { + var dateien = art == "movies" + ? db.MediaFiles.AsNoTracking().Where(f => f.MovieId == id) + : db.MediaFiles.AsNoTracking().Where(f => f.EpisodeId != null + && db.Episodes.Any(e => e.Id == f.EpisodeId && e.SeriesId == id)); + + // **Gezählt wird vollständig, gezeigt nur eine Handvoll.** Hier + // stand erst ein `Take(50)` vor dem Zählen — bei 1200 Folgen + // behauptete der Dialog dann „50 Dateien". Eine Zahl, die bei + // genau dem Titel falsch wird, bei dem man hinsieht. + // + // **Ein Zug, dann im Speicher.** Der Ordner aus einem Pfad zu + // schneiden lässt sich nicht nach SQL übersetzen — der Versuch + // endete in einem 500er. Die Pfade eines einzelnen Titels sind + // auch bei 1200 Folgen ein paar hundert Kilobyte; das ist + // billiger als eine Abfrage, die nicht läuft. + var pfade = await dateien.Select(f => f.Path).ToListAsync(); + if (pfade.Count == 0) return Results.Ok(new Herkunft(null, 0, [])); + + var ordner = pfade + .Select(p => Path.GetDirectoryName(p) ?? "") + .Distinct(StringComparer.Ordinal) + .ToList(); + + return Results.Ok(new Herkunft( + ordner.Count == 1 ? ordner[0] : GemeinsamerAnfang(ordner), + pfade.Count, + pfade.Order(StringComparer.Ordinal) + .Take(6) + .Select(Path.GetFileName) + .Where(n => n is not null) + .ToList()!)); + }).RequireAuthorization(FlaroPolicies.Administrator); + app.MapGet("/api/v1/movies/{id:guid}/matches", async ( Guid id, FlaroContext db, IMetadataProvider anbieter, string? query, int? year) => { @@ -85,4 +127,36 @@ public static class MatchEndpoints return Results.Ok(new MatchState(serie.TmdbId, serie.ImdbId, serie.MatchLocked, serie.Title, serie.Year)); }).RequireAuthorization(FlaroPolicies.Administrator); } + + /// Der längste Pfad, mit dem alle anfangen — oder null. + /// + /// + /// Verglichen wird nach Ordnerbestandteilen, nicht nach Zeichen: sonst + /// ergäben /media/Serien/Dark und /media/Serien/Darkwing den + /// gemeinsamen Anfang /media/Serien/Dark, und der bezeichnet einen + /// Ordner, in dem nur eines von beidem liegt. + /// + private static string? GemeinsamerAnfang(IReadOnlyList pfade) + { + if (pfade.Count == 0) return null; + + var teile = pfade.Select(p => p.Split('/', StringSplitOptions.RemoveEmptyEntries)).ToList(); + var gemeinsam = new List(); + + for (var i = 0; i < teile[0].Length; i++) + { + var stueck = teile[0][i]; + if (teile.Any(t => i >= t.Length || t[i] != stueck)) break; + gemeinsam.Add(stueck); + } + + return gemeinsam.Count == 0 ? null : "/" + string.Join('/', gemeinsam); + } } + +/// Woher ein Titel im Dateisystem stammt. +/// +/// Der gemeinsame Ordner aller Dateien; null, wenn es keinen gibt. +/// Wie viele Dateien an diesem Titel hängen. +/// Ein paar Dateinamen — mehr braucht niemand zum Wiedererkennen. +public sealed record Herkunft(string? Ordner, int Dateien, IReadOnlyList Beispiele); diff --git a/server/src/Flaro.Api/Program.cs b/server/src/Flaro.Api/Program.cs index 7befc0e..cd1198c 100644 --- a/server/src/Flaro.Api/Program.cs +++ b/server/src/Flaro.Api/Program.cs @@ -20,7 +20,15 @@ pfade.EnsureExists(); builder.Services.AddSingleton(pfade); builder.Services.AddSingleton(TimeProvider.System); -builder.Services.AddDbContext(o => o.UseSqlite($"Data Source={pfade.DatabaseFile}")); +// **Mit Wartezeit statt sofortigem Abbruch.** +// +// SQLite lässt genau einen Schreiber zu. Ohne `Default Timeout` scheitert +// jeder, der dazwischenkommt, auf der Stelle mit „database is locked" — +// während eines Scans über tausende Dateien ist das jede zweite Anfrage aus +// der Oberfläche. Dreissig Sekunden sind grosszügig; gebraucht werden +// Millisekunden. +builder.Services.AddDbContext(o => + o.UseSqlite($"Data Source={pfade.DatabaseFile};Default Timeout=30")); builder.Services .AddAuthentication(TokenAuthenticationHandler.SchemeName) @@ -124,7 +132,28 @@ var app = builder.Build(); // hochfährt, soll nicht erst eine Migration von Hand anstossen müssen. using (var bereich = app.Services.CreateScope()) { - await bereich.ServiceProvider.GetRequiredService().Database.MigrateAsync(); + var db = bereich.ServiceProvider.GetRequiredService(); + + // **WAL, und zwar vor der Migration.** + // + // In der Vorgabe („delete") sperrt ein Schreiber die ganze Datei: solange + // der Scan schreibt, kommt *keine* Leseanfrage durch. Genau so sah es in + // der Oberfläche aus — Bilddialog und Protokoll liefen während eines + // Laufs sporadisch ins Leere. Mit WAL lesen beliebig viele weiter, + // während einer schreibt. + // + // Die Einstellung hält sich in der Datei; einmal gesetzt bleibt sie. Sie + // trotzdem bei jedem Start zu setzen kostet nichts und deckt den Fall ab, + // dass jemand mit einer frischen Datei kommt. + await db.Database.ExecuteSqlRawAsync("PRAGMA journal_mode=WAL;"); + + // Ohne das wartet SQLite nach jeder Schreibung auf die Platte. Mit WAL + // ist „normal" die übliche Wahl: ein Absturz des Prozesses kostet nichts, + // nur ein Stromausfall mitten im Schreiben könnte die letzte Transaktion + // kosten — und die ist bei einem Medienserver ein Scanfortschritt. + await db.Database.ExecuteSqlRawAsync("PRAGMA synchronous=NORMAL;"); + + await db.Database.MigrateAsync(); } app.UseExceptionHandler(); diff --git a/web/app/src/api.ts b/web/app/src/api.ts index 1adcfc1..300e6e8 100644 --- a/web/app/src/api.ts +++ b/web/app/src/api.ts @@ -711,6 +711,12 @@ export const metadatenSetzen = (werte: { apiKey?: string; language?: string }) = export const vorschlaege = (art: 'movies' | 'series', id: string, suche?: string, jahr?: number) => anfrage(`/${art}/${id}/matches${abfrage({ query: suche, year: jahr })}`); +/** Woher ein Titel stammt — Ordner und ein paar Dateinamen. */ +export type Herkunft = { ordner: string | null; dateien: number; beispiele: string[] }; + +export const herkunft = (art: 'movies' | 'series', id: string) => + anfrage(`/${art}/${id}/herkunft`); + export const zuordnen = (art: 'movies' | 'series', id: string, tmdbId: number) => anfrage<{ tmdbId: number; imdbId: string | null; locked: boolean; title: string; year: number | null }>( `/${art}/${id}/match`, { method: 'PUT', body: JSON.stringify({ tmdbId }) }); diff --git a/web/app/src/main.ts b/web/app/src/main.ts index feca85b..94ac729 100644 --- a/web/app/src/main.ts +++ b/web/app/src/main.ts @@ -1,6 +1,7 @@ import './style.css'; import { systemauskunft, angemeldet, istAdmin, abmelden, sitzung, bibliotheken, + reihenfolgeSetzen, type Hinweis, type Bibliothek, } from './api'; import { icons, icon } from './icons'; @@ -133,6 +134,9 @@ function kopfleisteBauen(): void { /* ---------- Seitenleiste ---------- */ +/** Ob die Seitenleiste gerade umsortiert wird. */ +let leisteBearbeiten = false; + function leisteBauen(): void { leiste.innerHTML = ''; @@ -142,31 +146,112 @@ function leisteBauen(): void { // 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.kind === 'Shows' ? 'serien' : 'filme', b.itemCount)); + gruppe('Mediatheken', istAdmin() && mediatheken.length > 1); + mediatheken.forEach((b, i) => eintrag( + b.name, `#/m/${b.id}`, b.kind === 'Shows' ? 'serien' : 'filme', b.itemCount, i)); } if (istAdmin()) { gruppe('Server'); eintrag('Verwaltung', '#/verwaltung', 'verwaltung'); } - function gruppe(text: string): void { + /** Eine Gruppenüberschrift — bei den Mediatheken mit dem Stift daneben. + * + * **Der Griff sitzt dort, wo die Reihenfolge wirkt.** Vorher standen die + * Pfeile in den Einstellungen: man schob dort etwas und musste danach in + * die Seitenleiste sehen, ob es das war, was man wollte. + */ + function gruppe(text: string, mitStift = false): void { const d = document.createElement('div'); d.className = 'nav-gruppe'; - d.textContent = text; + + const t = document.createElement('span'); + t.textContent = text; + d.appendChild(t); + + if (mitStift) { + const stift = document.createElement('button'); + stift.className = 'nav-bearbeiten' + (leisteBearbeiten ? ' an' : ''); + stift.innerHTML = leisteBearbeiten + ? '' + icons.haken + '' + : '' + icons.stift + ''; + stift.title = leisteBearbeiten ? 'Fertig' : 'Seitenleiste bearbeiten'; + stift.setAttribute('aria-label', stift.title); + stift.onclick = () => { leisteBearbeiten = !leisteBearbeiten; leisteBauen(); }; + d.appendChild(stift); + } + leiste.appendChild(d); } function eintrag( - text: string, ziel: string, bild: keyof typeof icons, anzahl?: number | null, + text: string, ziel: string, bild: keyof typeof icons, + anzahl?: number | null, platz?: number, ): void { const b = document.createElement('button'); b.dataset.ziel = ziel; b.innerHTML = `${icons[bild]}` + `${esc(text)}` - + (anzahl != null ? `${anzahl}` : ''); - b.onclick = () => { location.hash = ziel; }; + + (anzahl != null && !leisteBearbeiten ? `${anzahl}` : ''); + + // Im Bearbeitungsmodus führt die Zeile nirgendwohin — sonst wechselt man + // beim Zielen auf einen Pfeil versehentlich die Ansicht. + if (leisteBearbeiten && platz != null) { + b.classList.add('verschiebbar'); + b.appendChild(pfeil('hoch', platz, platz - 1, platz === 0, text)); + b.appendChild(pfeil('runter', platz, platz + 1, platz === mediatheken.length - 1, text)); + } else { + b.onclick = () => { location.hash = ziel; }; + } + leiste.appendChild(b); } + + /** Ein Pfeil zum Schieben. + * + * **Zwei Knöpfe statt Ziehen und Ablegen.** Ziehen ist schöner, aber mit + * der Tastatur unbedienbar und auf dem Telefon ein Glücksspiel. + */ + function pfeil( + richtung: 'hoch' | 'runter', von: number, nach: number, gesperrt: boolean, name: string, + ): HTMLElement { + const p = document.createElement('span'); + p.className = 'nav-pfeil' + (gesperrt ? ' gesperrt' : ''); + p.setAttribute('role', 'button'); + if (!gesperrt) p.setAttribute('tabindex', '0'); + p.setAttribute('aria-label', `„${name}" nach ${richtung === 'hoch' ? 'oben' : 'unten'}`); + p.innerHTML = icons.hoch; + if (richtung === 'runter') p.style.transform = 'rotate(180deg)'; + + const los = (e: Event) => { + e.stopPropagation(); + if (gesperrt) return; + void schieben(von, nach); + }; + p.onclick = los; + p.onkeydown = e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); los(e); } }; + return p; + } +} + +/** Schiebt eine Mediathek und schickt die ganze neue Reihenfolge. + * + * **Die ganze Liste, nicht „eins hoch".** Zwei Benutzer, die gleichzeitig + * schieben, kämen sonst in einen Zustand, den keiner von beiden wollte. + */ +async function schieben(von: number, nach: number): Promise { + if (nach < 0 || nach >= mediatheken.length) return; + + const neu = [...mediatheken]; + const [eintrag] = neu.splice(von, 1); + neu.splice(nach, 0, eintrag!); + + // Sofort zeichnen, damit der Klick sich sofort anfühlt; der Server bekommt + // dieselbe Liste und widerspricht beim nächsten Laden, falls doch etwas + // schiefging. + mediatheken = neu; + leisteBauen(); + + try { await reihenfolgeSetzen(neu.map(b => b.id)); } + catch { await zaehlerAuffrischen(); } } /* ---------- Router ---------- */ diff --git a/web/app/src/style.css b/web/app/src/style.css index 53f4f20..294606f 100644 --- a/web/app/src/style.css +++ b/web/app/src/style.css @@ -164,8 +164,34 @@ svg { width: 20px; height: 20px; display: block; flex: none; } .nav-gruppe { font-size: 11px; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; color: var(--text-faint); padding: var(--s-loose) var(--s-loose) var(--s-snug); + display: flex; align-items: center; justify-content: space-between; gap: var(--s-snug); } +/* Der Stift erscheint beim Überfahren der Seitenleiste — ständig sichtbar + * wäre er ein Knopf, den man einmal im Jahr braucht, an einer Stelle, an der + * jeden Tag etwas anderes gesucht wird. Bei Tastaturfokus immer. */ +.nav-bearbeiten { + background: none; border: 0; padding: 2px; border-radius: var(--r-knopf); + color: var(--text-faint); opacity: 0; transition: opacity .12s ease; +} +#chrome:hover .nav-bearbeiten, +.nav-bearbeiten:focus-visible, +.nav-bearbeiten.an { opacity: 1; } +.nav-bearbeiten:hover { color: var(--text); } +.nav-bearbeiten.an { color: var(--accent); } +.nav-bearbeiten svg { width: 15px; height: 15px; display: block; } + +/* Im Bearbeitungsmodus trägt die Zeile zwei Pfeile statt der Zahl. */ +#chrome button.verschiebbar { cursor: default; } +.nav-pfeil { + display: inline-flex; align-items: center; justify-content: center; + width: 20px; height: 20px; flex: none; + border-radius: var(--r-knopf); color: var(--text-muted); +} +.nav-pfeil:hover { background: var(--raised); color: var(--text); } +.nav-pfeil.gesperrt { opacity: .25; pointer-events: none; } +.nav-pfeil svg { width: 14px; height: 14px; } + #chrome button { display: flex; align-items: center; gap: var(--s-regular); background: none; border: 0; text-align: left; @@ -933,6 +959,19 @@ h2 { font-size: var(--t-abschnitt); font-weight: 700; letter-spacing: -.015em; m .dlg-breit { width: min(860px, 100vw - 48px); } +/* Die Herkunft im Zuordnungsdialog — der Pfad bricht um statt abzuschneiden, + * weil ausgerechnet das Ende die Antwort trägt. */ +.quelle { + display: flex; flex-direction: column; gap: 2px; + padding: var(--s-snug) var(--s-regular); + background: var(--raised); border-radius: var(--r-knopf); + border-left: 2px solid var(--accent); +} +.quelle-pfad { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: var(--t-klein); color: var(--text); word-break: break-all; +} + .bildblock { display: flex; flex-direction: column; gap: var(--s-snug); } .bildblock h3 { margin: 0; font-size: var(--t-gross); font-weight: 600; flex: 1; } diff --git a/web/app/src/views/admin.ts b/web/app/src/views/admin.ts index 5634032..ae4853b 100644 --- a/web/app/src/views/admin.ts +++ b/web/app/src/views/admin.ts @@ -4,7 +4,7 @@ import { vermisste, vermisstesEntfernen, type VermissteDatei, protokolltage, protokollAdresse, sprachen, spracheSetzen, uebersetzungenHolen, meineSprache, zuPruefen, veroeffentlichen, zurueckziehen, pruefungSetzen, - mediathekFreigeben, reihenfolgeSetzen, mediathekZuruecksetzen, + mediathekFreigeben, mediathekZuruecksetzen, type Pruefeintrag, benutzer, benutzerAnlegen, metadatenEinstellung, metadatenSetzen, servernameSetzen, systemauskunft, @@ -14,7 +14,7 @@ import { } from '../api'; import { esc, dauer, groesse, knopf, fehlerzeile, leer, meldung } from '../util'; import { dialog, nachfragen } from '../dialog'; -import { icon, icons } from '../icons'; +import { icon } from '../icons'; /** Die Verwaltung. * @@ -545,21 +545,6 @@ function vermisstenbereich(geaendert: () => void): HTMLElement { /* ---------- Bibliotheken ---------- */ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLElement { - /** Die Liste, wie sie gerade steht — für das Umsortieren. */ - let alleBibliotheken: Bibliothek[] = []; - - /** Schiebt einen Eintrag und schickt die ganze neue Reihenfolge. */ - async function schieben(von: number, nach: number): Promise { - if (nach < 0 || nach >= alleBibliotheken.length) return; - - const neu = [...alleBibliotheken]; - const [eintrag] = neu.splice(von, 1); - neu.splice(nach, 0, eintrag!); - - await reihenfolgeSetzen(neu.map(b => b.id)); - await laden(); - geaendert(); - } const bereich = document.createElement('section'); @@ -613,15 +598,14 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle liste.appendChild(leer('Noch keine Mediathek. Lege oben eine an.')); return; } - alleBibliotheken = alle; - alle.forEach((b, i) => liste.appendChild(block(b, i))); + alle.forEach(b => liste.appendChild(block(b))); } catch (fehler) { liste.innerHTML = ''; liste.appendChild(fehlerzeile(meldung(fehler))); } } - function block(b: Bibliothek, platz: number): HTMLElement { + function block(b: Bibliothek): HTMLElement { const d = document.createElement('div'); d.className = 'einstellung'; @@ -649,12 +633,35 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle const los = knopf('Scannen', 'btn'); los.onclick = async () => { - los.disabled = true; + laufendSetzen(true); try { await scanStarten(b.id); await verfolgen(true); } - catch (fehler) { stand.textContent = meldung(fehler); } - los.disabled = false; + catch (fehler) { stand.textContent = meldung(fehler); laufendSetzen(false); } }; + /** Sperrt alles, was einen laufenden Lauf stören würde. + * + * **Nicht nur „Scannen".** Ein zweiter Scan prallt am Server ab und ist + * nur verwirrend — „Zurücksetzen" und „Entfernen" dagegen löschen Zeilen, + * während der Lauf welche schreibt, und das endet in einem Bestand, den + * niemand mehr erklären kann. + * + * **Umbenennen, Prüfung und die Pfeile bleiben bedienbar.** Sie rühren + * den Inhalt nicht an, und ein Knopf, der ohne Not gesperrt ist, ist + * genauso ärgerlich wie einer, der ohne Not etwas kaputtmacht. + */ + function laufendSetzen(an: boolean): void { + const grund = 'Läuft gerade — bitte warten, bis der Lauf durch ist.'; + [los, auffrischen, zuruecksetzen, entfernen].forEach(k => { + k.disabled = an; + if (an) k.title = grund; + else if (k.title === grund) k.title = ''; + }); + if (!an) { + auffrischen.title = 'Holt Titel, Bilder und Besetzung erneut — ' + + 'nötig nach einer Sprachänderung.'; + } + } + const auffrischen = knopf('Metadaten auffrischen', 'btn'); auffrischen.title = 'Holt Titel, Bilder und Besetzung erneut — ' + 'nötig nach einer Sprachänderung.'; @@ -664,7 +671,11 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle + 'nicht über eine neue Suche. Zuordnungen von Hand bleiben damit stehen. ' + 'Bei vielen Titeln dauert es.', bestaetigen: 'Auffrischen', - tun: async () => { await metadatenAuffrischen(b.id); void verfolgen(true); }, + tun: async () => { + laufendSetzen(true); + await metadatenAuffrischen(b.id); + void verfolgen(true); + }, }); const umbenennen = knopf('Umbenennen', 'btn'); @@ -721,15 +732,35 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle const s = await scanstand(b.id); // **Was der Server sagt, nicht was wir raten.** Vorher stand hier // minutenlang „Wird durchsucht…", ohne Hinweis, ob etwas passiert. - if (s.state === 'running') stand.textContent = s.step ?? 'Wird durchsucht…'; - else if (s.state === 'enriching') stand.textContent = s.step ?? 'Metadaten werden geholt…'; - else if (s.state === 'failed') { stand.textContent = s.error ?? 'Gescheitert.'; return; } + // **Die Sperre richtet sich nach dem Server, nicht nach dem Klick.** + // Beim Aufbau der Seite läuft vielleicht schon einer — dann müssen die + // Knöpfe gesperrt sein, ohne dass hier jemand etwas gedrückt hat. + if (s.state === 'running') { + laufendSetzen(true); + stand.textContent = s.step ?? 'Wird durchsucht…'; + } else if (s.state === 'enriching') { + laufendSetzen(true); + stand.textContent = s.step ?? 'Metadaten werden geholt…'; + } else if (s.state === 'failed') { + laufendSetzen(false); + stand.textContent = s.error ?? 'Gescheitert.'; + return; + } - // **Die Zahl mitlaufen lassen, nicht erst am Ende.** Der Scan schreibt - // laufend; wer dabei zusieht, soll das auch sehen. Alle fünf Runden, - // nicht jede: ein Abruf je Sekunde für eine Zahl wäre Verschwendung. - if (melden && i > 0 && i % 5 === 0) await laden(); - else if (s.state === 'finished') { + // **Alle Zähler auf demselben Takt.** + // + // Vorher frischte diese Schleife nur die Zeile in der Verwaltung auf, + // und die Seitenleiste erst am Ende des Laufs. In der Mediathek stand + // dann 20, in der Verwaltung 14 und in der Seitenleiste 0 — drei + // Zahlen für dieselbe Sache, und keine davon offensichtlich falsch. + // `geaendert()` baut nur die Seitenleiste neu, nicht die Ansicht; + // es hier zu rufen ist deshalb gefahrlos. + // + // 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 (s.state === 'finished') { const teile = [`${s.filesFound} Dateien`, `${s.added} neu`, `${s.removed} entfernt`]; // „ohne Folgennummer" stimmte nicht mehr: gezählt wird jetzt auch, // was eine Nummer trägt, aber zu keiner Serie gehört. @@ -739,11 +770,16 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle if (s.metadata?.error) teile.push(s.metadata.error); if (s.missing) teile.push(`${s.missing} nicht mehr gefunden`); stand.textContent = teile.join(' · '); + laufendSetzen(false); if (melden) { await laden(); geaendert(); } return; } await new Promise(r => setTimeout(r, 1000)); } + + // Nach fünfzehn Minuten ohne Ende: die Sperre fällt, sonst bliebe die + // Mediathek bis zum Neuladen der Seite unbedienbar. + laufendSetzen(false); } // Die Prüfung lässt sich je Mediathek umstellen — was beim Anlegen @@ -760,30 +796,14 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle } }; - // **Umsortieren über zwei Pfeile, nicht über Ziehen.** + // **Umsortiert wird in der Seitenleiste, nicht hier.** // - // Ziehen und Ablegen ist schöner, aber mit der Tastatur unbedienbar und - // auf dem Telefon ein Glücksspiel. Zwei Knöpfe kann jeder bedienen, und - // der Server bekommt die ganze Liste statt eines „eins hoch" — zwei - // Benutzer, die gleichzeitig schieben, kämen sonst in einen Zustand, den - // keiner von beiden wollte. - const hoch = knopf('', 'btn rund'); - hoch.innerHTML = icons.hoch; - hoch.setAttribute('aria-label', `„${b.name}“ nach oben`); - hoch.disabled = platz === 0; - hoch.onclick = () => void schieben(platz, platz - 1); - - const runter = knopf('', 'btn rund'); - runter.innerHTML = icons.hoch; - runter.style.transform = 'rotate(180deg)'; - runter.setAttribute('aria-label', `„${b.name}“ nach unten`); - runter.disabled = platz === alleBibliotheken.length - 1; - runter.onclick = () => void schieben(platz, platz + 1); - + // Die Reihenfolge wirkt dort, also gehört der Griff dorthin: man sieht + // beim Schieben, was man verändert, statt in den Einstellungen zu raten + // und danach nachzusehen. Der Knopf heisst „Seitenleiste bearbeiten". const knoepfe = document.createElement('div'); knoepfe.className = 'knopfreihe'; - knoepfe.append( - hoch, runter, los, auffrischen, pruefung, umbenennen, zuruecksetzen, entfernen); + knoepfe.append(los, auffrischen, pruefung, umbenennen, zuruecksetzen, entfernen); // **Nur wenn hier wirklich etwas liegt.** Ein Knopf, der nichts zu tun // hat, ist schlechter als keiner — und die Zahl daneben sagt, worum es diff --git a/web/app/src/zuordnung.ts b/web/app/src/zuordnung.ts index bf0fa10..9af5ae6 100644 --- a/web/app/src/zuordnung.ts +++ b/web/app/src/zuordnung.ts @@ -1,4 +1,4 @@ -import { vorschlaege, zuordnen } from './api'; +import { vorschlaege, zuordnen, herkunft } from './api'; import { esc, knopf, feld, fehlerzeile, leer, meldung } from './util'; import { icons } from './icons'; @@ -35,6 +35,36 @@ export function zuordnungsdialog( const koerper = document.createElement('div'); koerper.className = 'dlg-koerper'; + // **Woher der Titel stammt, über allem anderen.** + // + // Liegt die Zuordnung völlig daneben, sagt der angezeigte Titel nichts mehr + // darüber, welche Datei hier eigentlich gemeint ist — „Dark Shadows" von + // 1966 kann alles sein. Der Ordner sagt es in einer Zeile. + const quelle = document.createElement('div'); + quelle.className = 'quelle'; + quelle.innerHTML = 'Herkunft wird gelesen…'; + + void herkunft(art, id).then(h => { + quelle.innerHTML = ''; + + const ordner = document.createElement('div'); + ordner.className = 'quelle-pfad'; + ordner.textContent = h.ordner ?? 'Kein gemeinsamer Ordner'; + ordner.title = h.ordner ?? ''; + quelle.appendChild(ordner); + + const dazu = document.createElement('span'); + dazu.className = 'winzig'; + const namen = h.beispiele.join(' · '); + dazu.textContent = h.dateien === 0 + ? 'Keine Datei mehr zugeordnet.' + : `${h.dateien} ${h.dateien === 1 ? 'Datei' : 'Dateien'}` + + (namen ? ` — ${namen}${h.dateien > h.beispiele.length ? ' …' : ''}` : ''); + quelle.appendChild(dazu); + }).catch(() => { + quelle.innerHTML = 'Herkunft nicht lesbar.'; + }); + const hinweis = document.createElement('p'); hinweis.className = 'hinweis'; hinweis.textContent = 'Der gewählte Eintrag ersetzt Titel, Beschreibung, Bilder ' @@ -53,7 +83,7 @@ export function zuordnungsdialog( const treffer = document.createElement('div'); treffer.className = 'einstellungen'; - koerper.append(hinweis, reihe, treffer); + koerper.append(quelle, hinweis, reihe, treffer); d.append(kopf, koerper); d.addEventListener('close', () => d.remove());