diff --git a/README.md b/README.md
index 0f3d13b..18a1c90 100644
--- a/README.md
+++ b/README.md
@@ -1,77 +1,52 @@
# wordarr
-Sonarr/Radarr-Style Request- & Import-Manager für **Ebooks**, **Comics/Mangas** und **Audiobooks**.
+Request and import manager for **ebooks**, **comics/manga** and **audiobooks**, in
+the style of Sonarr/Radarr. Search metadata providers, keep a list of wanted
+titles, and move finished downloads into your library under a naming scheme of
+your choice.
-## Funktionsweise
-
-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; die Spalte *Library* daneben zeigt, wohin die zugeordnete Anfrage geschrieben wird, und lässt sich dort direkt umstellen. Über 🔍 lässt sich ein Eintrag mit einer bereits offenen Anfrage verbinden — wordarr schlägt passende aus dem Ordnernamen vor, und das Feld *Offene Anfragen* durchsucht auf Wunsch alle (mehrere Begriffe werden mit UND verknüpft, `drei 194` findet Band 194 der Serie). Alternativ lässt sich dort 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.
-
-Führt Audible eine Reihe nur teilweise (Lady Bedfort etwa mit 34 von 117 Folgen), legt **Anfragen aus Ordnernamen** die fehlenden in einem Zug an: Titel und Folgennummer kommen aus dem Ordnernamen, Serie und Autor gibst du einmal für alle ein, und die neuen Anfragen werden direkt mit ihren Einträgen verbunden. Bereits vorhandene Anfragen werden nicht doppelt angelegt, sondern nur verknüpft.
-
-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)
+## Deployment
```bash
docker compose up -d --build
-# Web-UI: http://localhost:8787
+# web UI: http://localhost:8787
```
-`docker-compose.yml` anpassen: `/mnt/downloads` und die Library-Roots so mounten, dass die in wordarr konfigurierten `root_path`-Werte im Container existieren. Download-Ordner und Libraries sollten auf demselben Mount liegen, sonst wird das Verschieben zum Kopieren+Löschen (funktioniert, dauert nur länger).
+Mount `/mnt/downloads` and your library roots so that the `root_path` values
+configured in wordarr exist inside the container. Keep downloads and libraries on
+the same mount, otherwise moving turns into copy + delete.
-| Env | Default | Beschreibung |
+| Env | Default | Description |
|---|---|---|
-| `WORDARR_DOWNLOAD_DIR` | `/mnt/downloads` | Gescannter Download-Ordner |
-| `WORDARR_CONFIG_DIR` | `/config` | Ablage der SQLite-DB |
+| `WORDARR_DOWNLOAD_DIR` | `/mnt/downloads` | scanned download folder |
+| `WORDARR_CONFIG_DIR` | `/config` | location of the SQLite database |
+| `WORDARR_AUDIBLE_REGIONS` | `de,com` | Audible marketplaces, first ranks first |
+| `WORDARR_MUSICBRAINZ_UA` | see `metadata/musicbrainz.py` | user agent MusicBrainz requires |
-## Metadaten korrigieren
+## Usage
-Audible führt Reihen manchmal anders, als man sie ablegen möchte (*Harry Potter* steht dort unter *Wizarding World*, und das landet über `{Series}` im Ordnernamen). Deshalb:
+1. **Libraries** – create one per target folder with a media type, a naming
+ scheme (`{Author}` `{Authors}` `{Narrator}` `{Narrators}` `{Title}` `{Year}`
+ `{Series}` `{Volume}`) and optionally a language that filters the search.
+2. **Search** – look up a title (ebooks: Open Library, audiobooks: Audible plus
+ MusicBrainz, manga: AniList) and request it, or add it by hand. For a series,
+ *Ganze Serie…* requests every episode at once.
+3. **Import** – scan the download folder. wordarr suggests file→request matches,
+ which you confirm or correct; entries can be merged, split, or turned into
+ requests from their folder names. Importing renames and moves the files
+ according to the library's scheme and writes audio tags.
-- **Vor dem Anlegen**: Im Import-Dialog übernimmt ✎ an einem Treffer dessen Felder (Titel, Autor, Serie, Band) in die Eingabemaske — Sprecher, Cover, Jahr und ASIN bleiben erhalten. Erst *Anlegen & verbinden* erzeugt die Anfrage.
-- **Danach**: Im Tab *Missing* bzw. *Importiert* öffnet ein Klick auf die Karte alle Felder. *Speichern & neu taggen* schreibt die ID3-Tags neu, **Speichern & neu ablegen** wendet das Namensschema der Library auf die bereits verschobenen Dateien an — der Ordner wird also umbenannt bzw. verschoben, Cover ziehen mit um und leer gewordene Ordner verschwinden. Ein belegter Zielordner bricht den Vorgang ab, statt etwas zu überschreiben — dasselbe gilt beim Import selbst: Rendern zwei Titel nach dem Namensschema denselben Pfad (etwa zwei Bände mit identischem Titel bei einem Schema ohne `{Volume}`), scheitert der zweite mit einer Meldung, statt den ersten zu überschreiben.
+Imported titles can be edited afterwards; *Speichern & neu ablegen* re-applies
+the naming scheme to files already in the library.
-## Dateinamen mit kaputter Kodierung
-
-Linux erlaubt in Dateinamen beliebige Bytes, ältere Rips tragen ihre Umlaute deshalb manchmal als Latin-1 (`Die Fu\xdfball-Falle` statt UTF-8). Python liest solche Namen mit Ersatzzeichen ein, die sich nicht als JSON ausliefern lassen — früher scheiterte daran der **gesamte** Scan mit einem 500er. wordarr zeigt betroffene Einträge jetzt mit `�` an und überträgt den Pfad verlustfrei im Hintergrund, sodass Scan und Import normal funktionieren. Beim Import bekommt die Datei ohnehin den Namen aus dem Schema — danach ist der Name sauber.
-
-## Metadaten-Quellen
-
-Audiobooks werden bei **Audible** gesucht und parallel bei **MusicBrainz**, dessen Treffer hinter den Audible-Ergebnissen stehen und mit der Quelle gekennzeichnet sind. MusicBrainz erfasst deutsche Hörspielserien gut und liefert oft die **Erstveröffentlichung** statt einer späteren Audible-Neuauflage (`Die drei ??? 100: Toteninsel` mit 2001 statt 2022) sowie Folgennummern, die dort im Titel stehen. Gesucht wird nur in Audio-Dramen und Hörbüchern, sonst kämen zu jedem Serientitel tausende Musikalben zurück.
-
-Der Dienst begrenzt auf eine Anfrage pro Sekunde; wordarr hält das ein und fragt ihn deshalb nur bei einer ausdrücklichen Suche, nicht bei der automatischen Suche beim Öffnen des Import-Dialogs. Fällt MusicBrainz aus, bleiben die Audible-Treffer davon unberührt. Der Serien-Crawl („Ganze Serie…") nutzt weiterhin nur Audible, da nur dort Serien über eine ASIN zusammenhängen. Über `WORDARR_MUSICBRAINZ_UA` lässt sich der User-Agent setzen, den MusicBrainz verlangt.
-
-Was beide nicht kennen (Lady Bedfort etwa führt Audible nur mit 34 von 117 Folgen, MusicBrainz gar nicht), legst du am schnellsten über *Anfragen aus Ordnernamen* an.
-
-## Sprachen
-
-Jede Library kann eine **Sprache** tragen (`Deutsch`/`Englisch`, Default: egal). Audible liefert zu jedem Titel die Sprache mit, deshalb zeigt die Suche für eine englische Library nur englische Ausgaben und für eine deutsche nur deutsche — praktisch, wenn dieselbe Reihe in beiden Sprachen in getrennten Libraries liegt (*A Song of Ice and Fire* vs. *Das Lied von Eis und Feuer*). Gesucht wird dann auch auf dem passenden Marktplatz (`audible.com` bzw. `audible.de`), was die Trefferqualität deutlich hebt. Im Tab *Suche* lässt sich die Sprache zusätzlich frei filtern; im Import-Dialog kommt sie automatisch aus der gewählten Ziel-Library.
-
-## Namensschemata
-
-Pro Library konfigurierbar (Tab *Libraries*), Platzhalter: `{Author}` `{Authors}` `{Narrator}` `{Narrators}` `{Title}` `{Year}` `{Series}` `{Volume}`.
-
-`{Author}` und `{Narrator}` nennen jeweils nur den **ersten** Namen, `{Authors}` und `{Narrators}` die vollständige Liste — bei einer Full-Cast-Produktion mit 20 Sprechern willst du Letzteres nicht im Ordnernamen. Fehlt ein Wert, verschwindet er samt Klammern und Trennzeichen: `{Series}/{Volume} - {Title} ({Narrator})` wird für einen Einzeltitel ohne Sprecherangabe schlicht zu `Titel`.
-
-Liegt dieselbe Ausgabe in mehreren Fassungen vor (Harry Potter mit Stephen Fry *und* als Full-Cast), gehört `{Narrator}` in den Ordnernamen — sonst kollidieren beide im selben Verzeichnis:
-
-```
-Harry Potter/01 - Philosopher's Stone (Stephen Fry)/
-Harry Potter/01 - Philosopher's Stone (Hugh Laurie)/
-```
-
-Defaults:
-- Ebook: `{Author}/{Title} ({Year})` / `{Author} - {Title}`
-- Audiobook: `{Author}/{Title}` / `{Title}`
-- Comic: `{Series}` / `{Series} - Band {Volume}`
-
-## Entwicklung
+## Development
```bash
python3 -m venv .venv && .venv/bin/pip install -e '.[dev]'
.venv/bin/pytest
-WORDARR_CONFIG_DIR=./config WORDARR_DOWNLOAD_DIR=/mnt/downloads .venv/bin/uvicorn wordarr.main:app --port 8787
+WORDARR_CONFIG_DIR=./config WORDARR_DOWNLOAD_DIR=/mnt/downloads \
+ .venv/bin/uvicorn wordarr.main:app --port 8787
```
+
+The browser tests in `tests/test_ui_dialogs.py` need `playwright` plus its
+chromium build and skip themselves when either is missing.
diff --git a/static/app.js b/static/app.js
index acbe7cd..ca9b010 100644
--- a/static/app.js
+++ b/static/app.js
@@ -20,7 +20,7 @@ async function api(path, opts = {}) {
return resp.json();
}
-// create a request; on a duplicate warning (409) ask the user before retrying
+// on a duplicate warning (409) ask before retrying
async function createRequest(body) {
try {
return await api("/api/requests", { method: "POST", body: JSON.stringify(body) });
@@ -48,7 +48,7 @@ function esc(s) {
return d.innerHTML;
}
-// last used library per media type (per-browser convenience)
+// last used library per media type
function getLastLib(type) {
try { return localStorage.getItem("wordarr.lastLib." + type); } catch { return null; }
}
@@ -278,7 +278,7 @@ function renderSeriesList() {
let previous = null;
$("#series-list").innerHTML = seriesEpisodes
.map((e, i) => {
- // make holes in the numbering visible - the crawl cannot reach every episode
+ // the crawl cannot reach every episode, show the holes
let gap = "";
if (e.volume != null && previous != null && e.volume > previous + 1) {
const missing = e.volume - previous - 1;
@@ -598,8 +598,7 @@ async function saveDetail(form) {
});
}
-// after correcting series/title/author the files are still filed under the old
-// name - this re-applies the library's scheme to what is already on disk
+// re-applies the library's scheme to files already on disk
$("#detail-relocate").addEventListener("click", async () => {
if (!detailRequest) return;
const btn = $("#detail-relocate");
@@ -669,13 +668,13 @@ $("#scan-btn").addEventListener("click", async () => {
]);
clearTimeout(cleanupTimer);
scanItems = scan.items;
- // selection state lives here, not in the DOM, so it survives paging/filtering
+ // selection lives here, not in the DOM, so it survives paging
scanItems.forEach((item) => {
item.request_id = item.suggested_request_id;
item.checked = !!item.suggested_request_id;
});
orphanedIds = new Set(scan.orphaned_request_ids || []);
- // an imported title whose files vanished is selectable like an open one
+ // an imported title whose files vanished counts as open again
missingReqs = [...reqs, ...done.filter((r) => orphanedIds.has(r.id))];
importedReqs = done.filter((r) => !orphanedIds.has(r.id));
importPage = 0;
@@ -743,8 +742,7 @@ function renderImportTable() {
.filter((r) => r.media_type === item.media_type)
.map((r) => option(r, orphanedIds.has(r.id) ? " ↺" : ""))
.join("");
- // already imported audiobooks can take further parts (a late CD, or one
- // that failed while its siblings went through)
+ // imported audiobooks can take further parts (a late CD)
const appendOpts = importedReqs
.filter((r) => r.media_type === item.media_type && r.imported_path)
.map((r) => option(r, " ↩︎"))
@@ -836,8 +834,7 @@ 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
+// target library of the assigned request, easy to miss inside the dialog
function libraryCell(item, i) {
const req = requestById(item.request_id);
if (!req) return '—';
@@ -899,8 +896,7 @@ $("#import-select-all").addEventListener("click", () => {
selectIndices(viewIdx, "insgesamt");
});
-// Entries without a suggestion are selected too - they are exactly the ones
-// "Anfragen aus Ordnernamen" works on.
+// entries without a suggestion are included, "Anfragen aus Ordnernamen" needs them
function selectIndices(indices, what) {
scanItems.forEach((it) => (it.checked = false)); // "take what I see", not "add"
indices.forEach((i) => {
@@ -924,8 +920,7 @@ $("#import-deselect-all").addEventListener("click", () => {
});
// ---- create requests straight from folder names ----
-// For series that Audible only knows in part (Lady Bedfort: 34 of 117) the
-// folder names are the better source - they carry number and title already.
+// for series Audible only knows in part, the folder names carry number and title
let namesTargets = [];
function nameToRequest(item) {
@@ -982,8 +977,7 @@ $("#names-submit").addEventListener("click", async () => {
method: "POST",
body: JSON.stringify({ library_id: parseInt(libId), items }),
});
- // reload so that entries skipped as duplicates can be linked to the request
- // that already existed
+ // reload so duplicates can be linked to the existing request
missingReqs = await api("/api/requests?status=missing");
const key = (t, v) => `${(t || "").trim().toLowerCase()}|${v ?? ""}`;
const byKey = new Map(missingReqs.map((r) => [key(r.title, r.volume), r]));
@@ -1014,7 +1008,7 @@ $("#names-submit").addEventListener("click", async () => {
});
// ---- multi-part episodes: import several entries as one audiobook ----
-// e.g. "100 - Toteninsel Teil 1/2/3", which Audible lists as a single title
+// e.g. "100 - Toteninsel Teil 1/2/3", one title for Audible
function commonPrefix(names) {
let prefix = names[0];
for (const n of names.slice(1)) {
@@ -1022,7 +1016,7 @@ function commonPrefix(names) {
while (i < prefix.length && i < n.length && prefix[i] === n[i]) i++;
prefix = prefix.slice(0, i);
}
- // drop a dangling part marker: "100 - Toteninsel Teil " -> "100 - Toteninsel"
+ // drop a dangling part marker
return prefix.replace(/[\s._\-–—:]*(?:teil|part|cd|disc|folge)?[\s._\-–—:]*$/i, "").trim();
}
@@ -1041,8 +1035,7 @@ $("#import-merge").addEventListener("click", () => {
toast("Die Einträge liegen in verschiedenen Ordnern", true);
return;
}
- // parts may share no prefix at all ("A - Sphinx", "B - Volk"): then the
- // folder holding them carries the real title ("100 - Toteninsel")
+ // "A - Sphinx", "B - Volk" share no prefix, then the folder carries the title
const parentName = (chosen[0].rel_dir || "").split("/").filter(Boolean).pop();
const name =
commonPrefix(chosen.map((it) => it.name)) || parentName || chosen[0].name;
@@ -1071,8 +1064,7 @@ function splitItem(i) {
if (item.parts) { // undo a merge
scanItems.splice(i, 1, ...item.parts);
} else if (item.is_dir && item.files.length > 1) {
- // a folder holding one book per file (a series bought as single titles):
- // turn every file into its own entry so each can get its own request
+ // one book per file: every file becomes its own entry
const parts = item.files.map((f) => ({
path: f,
name: f.split("/").pop().replace(/\.[^.]+$/, ""),
@@ -1101,8 +1093,7 @@ let quickAutoSearch = false; // the search fired on open, not by the user
let lastSeries = "";
// "017 - Titel", "Folge 17: Titel", "[003] Titel" -> episode number + rest.
-// A bare number without a separator ("100 Stunden") or without a leading zero is
-// left alone: there it is part of the title, not a numbering.
+// A bare number without separator or leading zero stays part of the title.
const EPISODE_PATTERNS = [
/^\s*(?:folge|teil|band|nr\.?)\s*(\d{1,4})\s*(?:[-–—._:]+\s*|\s+)/i, // "Folge 124: Titel"
/^\s*[\[(](\d{1,4})[\])]\s*(?:[-–—._:]+\s*|\s+)/, // "[003] Titel"
@@ -1132,8 +1123,7 @@ function cleanFileName(name) {
function openQuickDialog(i) {
quickItemIndex = i;
const item = scanItems[i];
- // the episode number wrecks the Audible keyword search, so it is kept out of
- // the query and used as the volume instead
+ // the number wrecks the Audible search, so it becomes the volume instead
const { number, rest } = splitEpisodeNumber(item.name);
quickEpisode = number;
$("#quick-file").textContent = (item.rel_dir ? item.rel_dir + "/" : "") + item.name;
@@ -1157,9 +1147,8 @@ function openQuickDialog(i) {
runQuickSearch();
}
-// offer linking to an already-open request instead of creating a duplicate.
-// Without a filter these are the best guesses from the file name; typing turns
-// the box into a search over every open request, because the guess can be wrong.
+// link to an open request instead of creating a duplicate: guesses from the
+// file name, or a search over all open requests once something is typed
const QUICK_OPEN_LIMIT = 20;
function renderQuickOpenRequests(item) {
@@ -1216,8 +1205,7 @@ async function runQuickSearch() {
const box = $("#quick-results");
box.innerHTML = "
Suche läuft…
";
try {
- // the target library decides the language: an "english" library should not
- // offer the German edition of the same book
+ // an "english" library should not offer the German edition
const lib = libraries.find((l) => String(l.id) === $("#quick-library").value);
const results = await api(
`/api/search?media_type=${item.media_type}&q=${encodeURIComponent($("#quick-q").value)}` +
@@ -1228,7 +1216,7 @@ async function runQuickSearch() {
box.innerHTML = `${EMPTY_HINTS[item.media_type]}
`;
return;
}
- // one compact row per hit: cover, title + a single meta line, action right
+ // one compact row per hit
box.innerHTML = results
.map((r, j) => {
const meta = [
@@ -1272,7 +1260,7 @@ $("#quick-search-form").addEventListener("submit", (e) => {
});
$("#quick-close").addEventListener("click", () => $("#quick-dialog").close());
-// create the request and wire it to the scanned entry the dialog was opened for
+// create the request and wire it to the scanned entry
async function requestAndConnect(body) {
const libId = $("#quick-library").value;
if (!libId) { toast("Erst eine Library für diesen Typ anlegen", true); return; }
@@ -1298,9 +1286,8 @@ function pickQuickResult(r) {
});
}
-// take a hit into the fields so title/author/series can be corrected before the
-// request is created - Audible files Harry Potter under "Wizarding World", and
-// the series ends up in the folder name
+// take a hit into the fields so it can be corrected first - Audible files
+// Harry Potter under "Wizarding World", which would end up in the folder name
function editBeforeRequest(r) {
quickPicked = r;
$("#quick-m-title").value = r.title || "";
@@ -1334,13 +1321,11 @@ $("#quick-m-submit").addEventListener("click", () => {
// ---- import execution (batched, with progress) ----
const IMPORT_BATCH_SIZE = 20;
-// finished rows disappear after a while: the result list below keeps the record,
-// and a shrinking table makes the remaining work obvious
+// finished rows disappear after a while, the result list keeps the record
const IMPORT_DONE_CLEANUP_MS = 15000;
let cleanupTimer = null;
-// several entries on one request means one audiobook split across folders:
-// importing them one by one only imports the first and fails the rest
+// several entries on one request: one audiobook split across folders
function groupByRequest(chosen) {
const byRequest = new Map();
for (const item of chosen) {
@@ -1364,8 +1349,7 @@ function buildImportGroups(groups) {
}));
}
-// Several entries on one request is either a multi-part title (merge them) or a
-// mismatch (skip them). The user decides once, then keeps the rest importable.
+// either a multi-part title (merge) or a mismatch (skip), decided once
function askAboutSharedRequests(shared) {
const dlg = $("#conflict-dialog");
$("#conflict-intro").textContent =
@@ -1402,7 +1386,7 @@ async function collapseSharedRequests(chosen) {
if (answer === "cancel") return null;
if (answer === "merge") return buildImportGroups(groups);
- // skip: mark the clashing entries, deselect them, import everything else
+ // mark and deselect the clashing entries, import everything else
const skipped = shared.flat();
skipped.forEach((it) => {
it.conflict = true;
@@ -1443,11 +1427,10 @@ $("#import-btn").addEventListener("click", async () => {
setImportProgress(0, total);
try {
- // one request per entry: the row can only turn green once its own move came
- // back, and a batch would only report after all of its items finished
+ // one request per entry, otherwise a row cannot turn green on its own
for (const { item, parts } of groups) {
parts.forEach((p) => (p.importState = "running"));
- // follow the running entry across pages, otherwise only the bar moves
+ // follow the running entry across pages
applyImportView();
const pos = viewIdx.indexOf(scanItems.indexOf(parts[0]));
if (pos >= 0) importPage = Math.floor(pos / IMPORT_PAGE_SIZE);
@@ -1493,7 +1476,7 @@ $("#import-btn").addEventListener("click", async () => {
refreshMissingBadge();
clearTimeout(cleanupTimer);
cleanupTimer = setTimeout(() => {
- // only the successful ones go; failures and conflicts stay to be dealt with
+ // failures and conflicts stay
const before = scanItems.length;
scanItems = scanItems.filter((it) => it.importState !== "done");
if (scanItems.length !== before) {
@@ -1504,8 +1487,7 @@ $("#import-btn").addEventListener("click", async () => {
`${scanItems.length} Kandidat(en) übrig — für den aktuellen Stand erneut scannen`;
}
}, IMPORT_DONE_CLEANUP_MS);
- // the list stays as it is, with its green and red rows - rescanning would
- // wipe exactly the feedback the user just waited for
+ // the list stays: rescanning would wipe the feedback
} finally {
btn.disabled = false;
btn.textContent = "Ausgewählte importieren";
diff --git a/static/style.css b/static/style.css
index fdad9ff..6817508 100644
--- a/static/style.css
+++ b/static/style.css
@@ -57,8 +57,7 @@ button:disabled { opacity: 0.6; cursor: default; }
.tag.warn { background: #8a6d1f; }
/* per-entry import feedback */
-/* :not([hidden]) matters: a plain "display: flex" would beat the hidden
- attribute and leave an empty bar sitting there (same trap as a