diff --git a/README.md b/README.md index 5f42c61..e3a9e89 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,9 @@ Sonarr/Radarr-Style Request- & Import-Manager für **Ebooks**, **Comics/Mangas** 1. **Anfragen**: Im Web-UI per Titel/Autor/ISBN suchen (Ebooks: Open Library · Audiobooks: Audible · Manga: AniList) oder manuell anlegen. Beim Request wählst du die **Ziel-Library** (z.B. deine 8 Bookorbit-Libraries oder die 3 Audiobookshelf-Libraries english/adult/kids). Der Titel erscheint als **Missing**. Gehört ein Audiobook-Treffer zu einer Serie, legt **„Ganze Serie…"** alle Folgen auf einmal an (Folgennummer + echter Titel von Audible, optional auf einen Folgenbereich eingegrenzt). Audible hat keine Serien-Abfrage — wordarr sammelt die Folgen über mehrere Suchläufe ein; sollte eine fehlen, zeigt der Dialog die Lücke an. 2. **Download-Ordner**: wordarr überwacht keinen Downloader aktiv — du legst Dateien selbst in den Download-Ordner (`/mnt/downloads`). -3. **Import missing**: Im Tab *Import* den Ordner scannen. wordarr schlägt per Fuzzy-Matching Datei→Request-Zuordnungen vor (bei Serien zählt die Folgennummer im Ordnernamen plus der Serienname im Pfad, `???` und `Fragezeichen` gelten als dasselbe); du bestätigst oder korrigierst. Über 🔍 lässt sich zu einem Ordner direkt eine Anfrage suchen — eine führende Folgennummer (`017 - Titel`) wird dabei aus der Suchanfrage genommen, weil Audible sonst schlechter trifft, und stattdessen als Band/Folge übernommen. Was Audible gar nicht führt (Live-Hörspiele, Fan-Editionen), legst du im selben Dialog unter *Nicht bei Audible? Manuell anlegen* an — Titel und Folge sind aus dem Ordnernamen vorbelegt, die Serie bleibt für den nächsten Eintrag stehen. Beim Import wird die Datei nach dem Namensschema der Library **umbenannt und verschoben**. Die Einträge werden einzeln abgearbeitet: die laufende Zeile ist blau markiert (die Ansicht blättert ihr nach), fertige werden grün, fehlgeschlagene rot mit der Fehlermeldung als Tooltip, und unten läuft ein Balken über alle Einträge. Erfolgreich importierte Zeilen verschwinden nach 15 Sekunden aus der Tabelle (die Liste darunter behält sie); Fehler und Konflikte bleiben stehen, bis du sie erledigt hast. Für den nächsten Durchgang erneut scannen. +3. **Import missing**: Im Tab *Import* den Ordner scannen. wordarr schlägt per Fuzzy-Matching Datei→Request-Zuordnungen vor (bei Serien zählt die Folgennummer im Ordnernamen plus der Serienname im Pfad, `???` und `Fragezeichen` gelten als dasselbe); du bestätigst oder korrigierst; die Spalte *Library* daneben zeigt, wohin die zugeordnete Anfrage geschrieben wird, und lässt sich dort direkt umstellen. Über 🔍 lässt sich zu einem Ordner direkt eine Anfrage suchen — eine führende Folgennummer (`017 - Titel`) wird dabei aus der Suchanfrage genommen, weil Audible sonst schlechter trifft, und stattdessen als Band/Folge übernommen. Was Audible gar nicht führt (Live-Hörspiele, Fan-Editionen), legst du im selben Dialog unter *Nicht bei Audible? Manuell anlegen* an — Titel und Folge sind aus dem Ordnernamen vorbelegt, die Serie bleibt für den nächsten Eintrag stehen. Beim Import wird die Datei nach dem Namensschema der Library **umbenannt und verschoben**. Die Einträge werden einzeln abgearbeitet: die laufende Zeile ist blau markiert (die Ansicht blättert ihr nach), fertige werden grün, fehlgeschlagene rot mit der Fehlermeldung als Tooltip, und unten läuft ein Balken über alle Einträge. Erfolgreich importierte Zeilen verschwinden nach 15 Sekunden aus der Tabelle (die Liste darunter behält sie); Fehler und Konflikte bleiben stehen, bis du sie erledigt hast. Für den nächsten Durchgang erneut scannen. -Zum Auswählen gibt es *Alle mit Vorschlag auswählen*, *Diese Seite auswählen* (nur die gerade sichtbaren 25, alles andere wird abgewählt — praktisch, um in Etappen zu importieren) und *Alle abwählen*. Ordner mit mehreren Audio-Dateien werden als ein Audiobook behandelt (`Titel - Part 01.mp3`, …) — der Download-Ordner selbst aber nie: lose Dateien direkt darin erscheinen einzeln, damit sie die Unterordner nicht verdecken. Enthält ein Ordner dagegen **je Datei ein eigenes Buch** (eine Reihe als Einzeltitel gekauft), teilt ✂️ ihn in einzelne Einträge auf; wordarr markiert solche Ordner mit `n Titel?`, wenn die Dateinamen verschiedene Titel tragen und jede Datei groß genug für ein ganzes Buch ist. Die Checkbox *Ordner als Einzeldateien behandeln* macht dasselbe für den gesamten Scan. Auch Ordner, die in Disc-Unterordner (`CD`, `CD1`, `CD 2`, `Disc 3`, `Teil 1`) aufgeteilt sind, gelten als **ein** Audiobook — der Ordnername darüber liefert den Titel, die Parts werden über alle Discs hinweg durchnummeriert. Unterordner ohne Audio (`Cover`, Scans, Booklets) werden dabei ignoriert. Mehrteiler in Unterordnern (`Teil A`/`Teil B`, `A - Titel`/`B - Titel`) zählen ebenfalls als ein Hörbuch. Liegen die Teile dagegen **nebeneinander** (`100 - Toteninsel Teil 1`, `… Teil 2`, …), markierst du sie und klickst *Ausgewählte zusammenfassen* — sie werden als ein Hörbuch mit durchlaufenden Parts importiert (✂️ löst das wieder auf). Liegen die Dateien einer bereits importierten Anfrage nicht mehr in der Library (zurück in den Download-Ordner verschoben, um sie nach einem neuen Namensschema abzulegen), gilt sie wieder als offen: der Scan schlägt sie ganz normal vor (mit `↺` markiert) und der Import legt sie neu ab. Sind mehrere Einträge derselben Anfrage zugeordnet, fragt wordarr vor dem Import nach: *zusammenfassen* (bei einem Mehrteiler) oder *überspringen* — dann werden nur die betroffenen Einträge zurückgestellt, rot markiert und über den Filter *Nur Konflikte* wiedergefunden, während alles andere importiert wird. Ohne diese Abfrage würde nur der erste Eintrag importiert und der Rest scheitern. Fehlt einem bereits importierten Hörbuch später ein Teil, wählst du es im Zuordnungs-Dropdown unter *Bereits importiert — Teile anhängen* (↩︎); die neuen Dateien werden hinten angehängt und alle Tracks neu getaggt. +Zum Auswählen gibt es *Alle mit Vorschlag auswählen*, *Diese Seite auswählen* (nur die gerade sichtbaren 25, alles andere wird abgewählt — praktisch, um in Etappen zu importieren) und *Alle abwählen*. Ordner mit mehreren Audio-Dateien werden als ein Audiobook behandelt (`Titel - Part 01.mp3`, …) — der Download-Ordner selbst aber nie: lose Dateien direkt darin erscheinen einzeln, damit sie die Unterordner nicht verdecken. Enthält ein Ordner dagegen **je Datei ein eigenes Buch** (eine Reihe als Einzeltitel gekauft), teilt ✂️ ihn in einzelne Einträge auf — der Button erscheint nur bei solchen Ordnern und bei Zusammenfassungen; wordarr markiert sie mit `n Titel?`, wenn die Dateinamen verschiedene Titel tragen und jede Datei groß genug für ein ganzes Buch ist. Die Checkbox *Ordner als Einzeldateien behandeln* macht dasselbe für den gesamten Scan. Auch Ordner, die in Disc-Unterordner (`CD`, `CD1`, `CD 2`, `Disc 3`, `Teil 1`) aufgeteilt sind, gelten als **ein** Audiobook — der Ordnername darüber liefert den Titel, die Parts werden über alle Discs hinweg durchnummeriert. Unterordner ohne Audio (`Cover`, Scans, Booklets) werden dabei ignoriert. Mehrteiler in Unterordnern (`Teil A`/`Teil B`, `A - Titel`/`B - Titel`) zählen ebenfalls als ein Hörbuch. Liegen die Teile dagegen **nebeneinander** (`100 - Toteninsel Teil 1`, `… Teil 2`, …), markierst du sie und klickst *Ausgewählte zusammenfassen* — sie werden als ein Hörbuch mit durchlaufenden Parts importiert (✂️ löst das wieder auf). Liegen die Dateien einer bereits importierten Anfrage nicht mehr in der Library (zurück in den Download-Ordner verschoben, um sie nach einem neuen Namensschema abzulegen), gilt sie wieder als offen: der Scan schlägt sie ganz normal vor (mit `↺` markiert) und der Import legt sie neu ab. Sind mehrere Einträge derselben Anfrage zugeordnet, fragt wordarr vor dem Import nach: *zusammenfassen* (bei einem Mehrteiler) oder *überspringen* — dann werden nur die betroffenen Einträge zurückgestellt, rot markiert und über den Filter *Nur Konflikte* wiedergefunden, während alles andere importiert wird. Ohne diese Abfrage würde nur der erste Eintrag importiert und der Rest scheitern. Fehlt einem bereits importierten Hörbuch später ein Teil, wählst du es im Zuordnungs-Dropdown unter *Bereits importiert — Teile anhängen* (↩︎); die neuen Dateien werden hinten angehängt und alle Tracks neu getaggt. ## Setup (Docker) diff --git a/static/app.js b/static/app.js index 01ffcbe..8b98990 100644 --- a/static/app.js +++ b/static/app.js @@ -764,10 +764,11 @@ function renderImportTable() { ${appendOpts ? `${appendOpts}` : ""} - ${item.parts || (item.is_dir && item.files.length > 1) + ${item.parts || item.maybe_separate ? `` : ""} + ${libraryCell(item, i)} ${item.importState ? importStateCell(item) : item.conflict ? '⚠ Konflikt' : scoreCell(item)} @@ -782,6 +783,32 @@ function renderImportTable() { tbody.querySelectorAll("[data-quick]").forEach((b) => b.addEventListener("click", () => openQuickDialog(parseInt(b.dataset.quick))) ); + tbody.querySelectorAll("[data-lib-for]").forEach((sel) => + sel.addEventListener("change", async () => { + const item = scanItems[sel.dataset.libFor]; + const req = requestById(item.request_id); + if (!req) return; + const previous = req.library_id; + req.library_id = parseInt(sel.value); + try { + const saved = await api("/api/requests/" + req.id, { + method: "PUT", + body: JSON.stringify({ + library_id: req.library_id, title: req.title, authors: req.authors, + narrator: req.narrator || "", series: req.series, volume: req.volume, + year: req.year, external_id: req.external_id, cover_url: req.cover_url, + }), + }); + Object.assign(req, saved); + toast(`„${req.title}" → ${saved.library_name}`); + } catch (err) { + req.library_id = previous; + sel.value = String(previous); + toast(err.message, true); + } + renderImportTable(); + }) + ); tbody.querySelectorAll("[data-split]").forEach((b) => b.addEventListener("click", () => splitItem(parseInt(b.dataset.split))) ); @@ -808,6 +835,23 @@ function renderImportTable() { updateImportPageInfo(pages); } +// which library the assigned request writes to - visible in the row, because the +// select inside the search dialog is easy to miss +function libraryCell(item, i) { + const req = requestById(item.request_id); + if (!req) return ''; + const opts = libraries + .filter((l) => l.media_type === req.media_type) + .map((l) => ``) + .join(""); + return ``; +} + +function requestById(id) { + if (!id) return null; + return missingReqs.find((r) => r.id === id) || importedReqs.find((r) => r.id === id) || null; +} + function importStateCell(item) { if (item.importState === "running") { return ' läuft…'; diff --git a/static/index.html b/static/index.html index 4232849..02300c0 100644 --- a/static/index.html +++ b/static/index.html @@ -184,6 +184,7 @@ Datei/Ordner Typ Zuordnung + Library Score diff --git a/static/style.css b/static/style.css index ca21305..fdad9ff 100644 --- a/static/style.css +++ b/static/style.css @@ -57,7 +57,9 @@ button:disabled { opacity: 0.6; cursor: default; } .tag.warn { background: #8a6d1f; } /* per-entry import feedback */ -#import-progress { display: flex; align-items: center; gap: 0.6rem; flex: 1; } +/* :not([hidden]) matters: a plain "display: flex" would beat the hidden + attribute and leave an empty bar sitting there (same trap as a ) */ +#import-progress:not([hidden]) { display: flex; align-items: center; gap: 0.6rem; flex: 1; } #import-progress progress { flex: 1; max-width: 26rem; height: 0.7rem; } tr.row-running { background: rgba(90, 140, 220, 0.12); } tr.row-done { background: rgba(80, 170, 110, 0.12); } diff --git a/tests/test_ui_dialogs.py b/tests/test_ui_dialogs.py index 0df4e45..961ebc5 100644 --- a/tests/test_ui_dialogs.py +++ b/tests/test_ui_dialogs.py @@ -77,6 +77,14 @@ def test_dialogs_start_closed(page): assert not page.locator("#quick-dialog").is_visible() +def test_hidden_elements_really_are_hidden(page): + """A CSS display rule can override the hidden attribute - it did for the + dialog and again for the progress bar.""" + page.click("nav button[data-view=import]") + for sel in ("#import-progress", "#import-btn", "#import-table", "#missing-badge"): + assert not page.locator(sel).is_visible(), sel + + def test_import_dialog_opens_and_closes(page): page.click("nav button[data-view=import]") page.click("#scan-btn")