Drei Zähler für dieselbe Sache, und keiner davon offensichtlich falsch

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 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-04 16:31:50 +02:00
co-authored by Claude Opus 5
parent e1b3625809
commit a22fa9b58e
7 changed files with 348 additions and 65 deletions
@@ -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);
}
/// <summary>Der längste Pfad, mit dem alle anfangen — oder <c>null</c>.</summary>
///
/// <remarks>
/// Verglichen wird nach Ordnerbestandteilen, nicht nach Zeichen: sonst
/// ergäben <c>/media/Serien/Dark</c> und <c>/media/Serien/Darkwing</c> den
/// gemeinsamen Anfang <c>/media/Serien/Dark</c>, und der bezeichnet einen
/// Ordner, in dem nur eines von beidem liegt.
/// </remarks>
private static string? GemeinsamerAnfang(IReadOnlyList<string> pfade)
{
if (pfade.Count == 0) return null;
var teile = pfade.Select(p => p.Split('/', StringSplitOptions.RemoveEmptyEntries)).ToList();
var gemeinsam = new List<string>();
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);
}
}
/// <summary>Woher ein Titel im Dateisystem stammt.</summary>
///
/// <param name="Ordner">Der gemeinsame Ordner aller Dateien; <c>null</c>, wenn es keinen gibt.</param>
/// <param name="Dateien">Wie viele Dateien an diesem Titel hängen.</param>
/// <param name="Beispiele">Ein paar Dateinamen — mehr braucht niemand zum Wiedererkennen.</param>
public sealed record Herkunft(string? Ordner, int Dateien, IReadOnlyList<string> Beispiele);
+31 -2
View File
@@ -20,7 +20,15 @@ pfade.EnsureExists();
builder.Services.AddSingleton(pfade);
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddDbContext<FlaroContext>(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<FlaroContext>(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<FlaroContext>().Database.MigrateAsync();
var db = bereich.ServiceProvider.GetRequiredService<FlaroContext>();
// **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();
+6
View File
@@ -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<Vorschlag[]>(`/${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<Herkunft>(`/${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 }) });
+93 -8
View File
@@ -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
? '<span class="icon">' + icons.haken + '</span>'
: '<span class="icon">' + icons.stift + '</span>';
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 = `<span class="icon">${icons[bild]}</span>`
+ `<span class="name">${esc(text)}</span>`
+ (anzahl != null ? `<span class="zahl">${anzahl}</span>` : '');
b.onclick = () => { location.hash = ziel; };
+ (anzahl != null && !leisteBearbeiten ? `<span class="zahl">${anzahl}</span>` : '');
// 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<void> {
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 ---------- */
+39
View File
@@ -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; }
+73 -53
View File
@@ -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<void> {
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
+32 -2
View File
@@ -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 = '<span class="winzig">Herkunft wird gelesen…</span>';
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 = '<span class="winzig">Herkunft nicht lesbar.</span>';
});
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());