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());