import { bibliotheken, bibliothekAnlegen, bibliothekUmbenennen, bibliothekLoeschen, scanStarten, scanstand, metadatenAuffrischen, vermisste, vermisstesEntfernen, type VermissteDatei, pruefungSetzen, mediathekZuruecksetzen, type Bibliothek, } from '../../api'; import { esc, knopf, fehlerzeile, leer, meldung } from '../../util'; import { dialog, nachfragen } from '../../dialog'; import { abschnitt } from './teile'; /** Mediatheken anlegen, einlesen, aufräumen. */ export function bibliotheksbereich( geaendert: (bekannt?: Bibliothek[]) => void, signal: AbortSignal, ): HTMLElement { const bereich = document.createElement('section'); const anlegen = knopf('Mediathek hinzufügen', 'btn btn-primary'); anlegen.onclick = () => dialog({ titel: 'Mediathek hinzufügen', felder: [ { name: 'name', beschriftung: 'Name', platzhalter: 'etwa Filme' }, { name: 'art', beschriftung: 'Art', auswahl: [{ wert: 'Movies', text: 'Filme' }, { wert: 'Shows', text: 'Serien' }], }, { name: 'pfad', beschriftung: 'Ordner', art: 'ordner', platzhalter: '/media/Filme', hinweis: 'Der Pfad im Container, nicht der auf dem NAS — ' + 'also das, was in der Compose-Datei hinter dem Doppelpunkt steht.', }, { name: 'pruefen', beschriftung: 'Neue Funde erst prüfen', schalter: true, hinweis: 'Aus heisst: gefundene Titel erscheinen sofort. An heisst: sie ' + 'warten unter „Zu prüfen", bis du sie freigibst — Zeit, die Zuordnung ' + 'und die Bilder zu kontrollieren oder einen Termin zu setzen.', }, ], bestaetigen: 'Anlegen', tun: async wert => { await bibliothekAnlegen(wert('name'), wert('art'), wert('pfad'), wert('pruefen') === 'true'); await laden(); geaendert(); }, }); bereich.appendChild(abschnitt('Mediatheken', [anlegen])); 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(); /** **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): 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 kopf = document.createElement('div'); kopf.className = 'zeile'; d.appendChild(kopf); function kopfSchreiben(): void { const serie = aktuell.kind === 'Shows'; // **Alle Zahlen nebeneinander, weil sie Verschiedenes meinen.** // // Plex zählte 451 Filme, Flaro 449, und der Scan lief über 461 Dateien. // Keine davon war falsch — es sind Titel, Dateien und zweite Fassungen. // Einzeln gezeigt bleibt die Frage offen, nebeneinander beantwortet sie // sich. const teile: string[] = [aktuell.path]; if (aktuell.itemCount != null) { teile.push(`${aktuell.itemCount} ` + (aktuell.itemCount === 1 ? (serie ? 'Serie' : 'Film') : (serie ? 'Serien' : 'Filme'))); } if (serie && aktuell.episodeCount != null) { teile.push(`${aktuell.episodeCount} ${aktuell.episodeCount === 1 ? 'Folge' : 'Folgen'}`); } teile.push(`${aktuell.fileCount} ${aktuell.fileCount === 1 ? 'Datei' : 'Dateien'}`); if (aktuell.extraVersionCount > 0) { teile.push(`${aktuell.extraVersionCount} zweite Fassung` + (aktuell.extraVersionCount === 1 ? '' : 'en')); } if (aktuell.unassignedCount > 0) teile.push(`${aktuell.unassignedCount} nicht zugeordnet`); if (aktuell.pendingCount > 0) { teile.push(`${aktuell.pendingCount} wartet${aktuell.pendingCount === 1 ? '' : 'en'} auf Freigabe`); } kopf.innerHTML = `${esc(aktuell.name)}` + `${esc(teile.join(' · '))}`; } kopfSchreiben(); const stand = document.createElement('div'); stand.className = 'hinweis'; const los = knopf('Scannen', 'btn'); los.onclick = async () => { laufendSetzen(true); try { await scanStarten(aktuell.id); await verfolgen(true); } 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.'; auffrischen.onclick = () => nachfragen({ 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(aktuell.id); void verfolgen(true); }, }); const umbenennen = knopf('Umbenennen', 'btn'); umbenennen.onclick = () => dialog({ titel: 'Mediathek umbenennen', felder: [{ name: 'name', beschriftung: 'Name', wert: aktuell.name }], bestaetigen: 'Speichern', tun: async wert => { await bibliothekUmbenennen(aktuell.id, wert('name')); await laden(); geaendert(); }, }); // **Zurücksetzen steht neben Entfernen, weil es fast dasselbe tut** — nur // ohne Namen, Pfad und Einstellungen mitzunehmen. Wer eine Mediathek // löscht und neu anlegt, um sie sauber zu bekommen, tippt alles noch // einmal; genau das war bisher der einzige Weg. const zuruecksetzen = knopf('Zurücksetzen', 'btn'); zuruecksetzen.onclick = () => nachfragen({ 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(aktuell.id); await laden(); geaendert(); }, }); const entfernen = knopf('Entfernen', 'btn'); entfernen.onclick = () => nachfragen({ 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(aktuell.id); await laden(); geaendert(); }, }); /** Fragt nach, bis der Lauf durch ist — er läuft im Hintergrund. */ /// Verfolgt einen Lauf bis zum Ende. /// /// **`melden` entscheidet, ob am Schluss `geaendert()` gerufen wird.** /// Beim Aufbau der Seite ist es `false`: ein längst beendeter Lauf meldete /// sonst „fertig", liess neu zeichnen, und der Neuaufbau verfolgte wieder /// denselben beendeten Lauf — eine Endlosschleife, die sich als vier /// dauernd drehende Spinner zeigte. async function verfolgen(melden: boolean): Promise { for (let i = 0; i < 900; i++) { if (signal.aborted) return; 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.** // 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; } // **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) geaendert(await laden()); 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. if (s.unrecognised) teile.push(`${s.unrecognised} nicht zugeordnet`); if (s.metadata?.skipped) teile.push('Metadaten übersprungen'); else if (s.metadata) teile.push(`${s.metadata.moviesMatched + s.metadata.seriesMatched} zugeordnet`); 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 // entschieden wurde, soll kein Urteil fürs Leben sein. const pruefung = knopf(aktuell.reviewNewItems ? 'Prüfung an' : 'Prüfung aus', 'btn'); pruefung.onclick = async () => { pruefung.disabled = true; try { await pruefungSetzen(aktuell.id, !aktuell.reviewNewItems); await laden(); } catch (fehler) { d.appendChild(fehlerzeile(meldung(fehler))); pruefung.disabled = false; } }; // **Umsortiert wird in der Seitenleiste, nicht hier.** // // 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(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 // geht, ohne dass man erst weiterscrollen muss. // // 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'); // **Zeigen statt blind freigeben.** // // Der Knopf veröffentlichte bisher auf einen Klick alles, was wartete — // ohne dass man gesehen hätte, was. Auf die Frage „wo sehe ich die vier // Filme?" gab es damit keine Antwort: die Liste stand weiter oben, aber // niemand wusste das, und sie war obendrein nicht nachgeladen worden. // // Jetzt führt er dorthin. Veröffentlicht wird dort, wo die Titel mit // Plakat und Dateiname stehen und sich einzeln anhaken lassen. // // **Ein Bereichswechsel, kein Sprung in der Seite.** Solange alles in // einer Spalte stand, genügte `scrollIntoView`; seit „Zu prüfen" ein // eigener Bereich ist, steht die Liste gar nicht mehr im selben Dokument. freigeben.onclick = () => { location.hash = '#/verwaltung/pruefung'; }; knoepfe.appendChild(freigeben); function freigabeSchreiben(): void { // **`hidden` reicht hier nicht.** Das Attribut wirkt über // `display: none` mit der Spezifität von Null — `.btn` setzt // `display: inline-flex` und gewinnt. Der Knopf stand deshalb als // „0 Titel freigeben" da, obwohl nichts wartet. `.hidden` trägt ein // `!important` und setzt sich durch. freigeben.classList.toggle('hidden', aktuell.pendingCount === 0); freigeben.textContent = aktuell.pendingCount === 1 ? 'Ein Titel wartet — ansehen' : `${aktuell.pendingCount} Titel warten — ansehen`; } 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 { wurzel: d, aktualisieren: (neu: Bibliothek) => { aktuell = neu; kopfSchreiben(); pruefung.textContent = aktuell.reviewNewItems ? 'Prüfung an' : 'Prüfung aus'; freigabeSchreiben(); }, }; } return bereich; } /* ---------- Nicht mehr gefunden ---------- */ /** Was in der Mediathek steht, aber nicht mehr auf der Platte liegt. * * **Erscheint nur, wenn es etwas zu melden gibt.** Ein dauerhaft leerer * Abschnitt wäre Rauschen; ein gefüllter ist eine Nachricht. * * Der Scan löscht nichts mehr von selbst. Eine kurz nicht eingehängte Platte * sah vorher aus wie „die Filme sind weg" und kostete Titel samt * Sehfortschritt aller Benutzer. Beides ist für den Scan nicht zu * unterscheiden — also entscheidet hier ein Mensch. */ export function vermisstenbereich(geaendert: () => void): HTMLElement { const bereich = document.createElement('section'); bereich.className = 'hidden'; const liste = document.createElement('div'); liste.className = 'einstellungen'; const alle = knopf('Alle entfernen', 'btn btn-gefahr'); alle.onclick = () => nachfragen({ titel: 'Alle vermissten Einträge entfernen', text: 'Entfernt alle Dateien, die nicht mehr gefunden werden, aus der ' + 'Mediathek — mitsamt den Titeln, von denen keine Datei übrig bleibt. ' + 'Auf der Platte wird nichts gelöscht.', bestaetigen: 'Entfernen', tun: async () => { await vermisstesEntfernen(); await laden(); geaendert(); }, }); bereich.append(abschnitt('Nicht mehr gefunden', [alle]), liste); void laden(); async function laden(): Promise { let fehlend: VermissteDatei[]; try { fehlend = await vermisste(); } catch { bereich.classList.add('hidden'); return; } if (fehlend.length === 0) { bereich.classList.add('hidden'); return; } bereich.classList.remove('hidden'); liste.innerHTML = ''; fehlend.forEach(v => { const d = document.createElement('div'); d.className = 'einstellung'; const oben = document.createElement('div'); oben.className = 'knopfreihe'; const text = document.createElement('div'); text.className = 'zeile'; text.style.flex = '1'; const seit = new Date(v.missingSince).toLocaleDateString('de-CH', { day: '2-digit', month: 'long', year: 'numeric' }); text.innerHTML = `${esc(v.title ?? '(ohne Titel)')}` + `${v.versionLabel ? ` · ${esc(v.versionLabel)}` : ''}` + `${esc(v.libraryName)} · vermisst seit ${esc(seit)}`; oben.appendChild(text); const weg = knopf('Entfernen', 'btn'); weg.onclick = async () => { weg.disabled = true; try { await vermisstesEntfernen(v.fileId); await laden(); geaendert(); } catch (fehler) { d.appendChild(fehlerzeile(meldung(fehler))); weg.disabled = false; } }; oben.appendChild(weg); d.appendChild(oben); const pfad = document.createElement('div'); pfad.className = 'winzig'; pfad.textContent = v.path; d.appendChild(pfad); liste.appendChild(d); }); } return bereich; } /* ---------- Bibliotheken ---------- */ /** Eine gezeichnete Mediathekszeile und der Weg, sie fortzuschreiben. */ type Mediathekzeile = { wurzel: HTMLElement; aktualisieren: (b: Bibliothek) => void; };