docs: shorten the readme and trim code comments

This commit is contained in:
Steppenstreuner
2026-08-29 16:18:37 +02:00
parent 8a05d2d3c3
commit 551352e838
16 changed files with 132 additions and 229 deletions
+33 -58
View File
@@ -1,77 +1,52 @@
# wordarr # 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 ## Deployment
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)
```bash ```bash
docker compose up -d --build 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_DOWNLOAD_DIR` | `/mnt/downloads` | scanned download folder |
| `WORDARR_CONFIG_DIR` | `/config` | Ablage der SQLite-DB | | `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. Imported titles can be edited afterwards; *Speichern & neu ablegen* re-applies
- **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. the naming scheme to files already in the library.
## Dateinamen mit kaputter Kodierung ## Development
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
```bash ```bash
python3 -m venv .venv && .venv/bin/pip install -e '.[dev]' python3 -m venv .venv && .venv/bin/pip install -e '.[dev]'
.venv/bin/pytest .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.
+32 -50
View File
@@ -20,7 +20,7 @@ async function api(path, opts = {}) {
return resp.json(); 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) { async function createRequest(body) {
try { try {
return await api("/api/requests", { method: "POST", body: JSON.stringify(body) }); return await api("/api/requests", { method: "POST", body: JSON.stringify(body) });
@@ -48,7 +48,7 @@ function esc(s) {
return d.innerHTML; return d.innerHTML;
} }
// last used library per media type (per-browser convenience) // last used library per media type
function getLastLib(type) { function getLastLib(type) {
try { return localStorage.getItem("wordarr.lastLib." + type); } catch { return null; } try { return localStorage.getItem("wordarr.lastLib." + type); } catch { return null; }
} }
@@ -278,7 +278,7 @@ function renderSeriesList() {
let previous = null; let previous = null;
$("#series-list").innerHTML = seriesEpisodes $("#series-list").innerHTML = seriesEpisodes
.map((e, i) => { .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 = ""; let gap = "";
if (e.volume != null && previous != null && e.volume > previous + 1) { if (e.volume != null && previous != null && e.volume > previous + 1) {
const missing = 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 // re-applies the library's scheme to files already on disk
// name - this re-applies the library's scheme to what is already on disk
$("#detail-relocate").addEventListener("click", async () => { $("#detail-relocate").addEventListener("click", async () => {
if (!detailRequest) return; if (!detailRequest) return;
const btn = $("#detail-relocate"); const btn = $("#detail-relocate");
@@ -669,13 +668,13 @@ $("#scan-btn").addEventListener("click", async () => {
]); ]);
clearTimeout(cleanupTimer); clearTimeout(cleanupTimer);
scanItems = scan.items; 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) => { scanItems.forEach((item) => {
item.request_id = item.suggested_request_id; item.request_id = item.suggested_request_id;
item.checked = !!item.suggested_request_id; item.checked = !!item.suggested_request_id;
}); });
orphanedIds = new Set(scan.orphaned_request_ids || []); 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))]; missingReqs = [...reqs, ...done.filter((r) => orphanedIds.has(r.id))];
importedReqs = done.filter((r) => !orphanedIds.has(r.id)); importedReqs = done.filter((r) => !orphanedIds.has(r.id));
importPage = 0; importPage = 0;
@@ -743,8 +742,7 @@ function renderImportTable() {
.filter((r) => r.media_type === item.media_type) .filter((r) => r.media_type === item.media_type)
.map((r) => option(r, orphanedIds.has(r.id) ? " ↺" : "")) .map((r) => option(r, orphanedIds.has(r.id) ? " ↺" : ""))
.join(""); .join("");
// already imported audiobooks can take further parts (a late CD, or one // imported audiobooks can take further parts (a late CD)
// that failed while its siblings went through)
const appendOpts = importedReqs const appendOpts = importedReqs
.filter((r) => r.media_type === item.media_type && r.imported_path) .filter((r) => r.media_type === item.media_type && r.imported_path)
.map((r) => option(r, " ↩︎")) .map((r) => option(r, " ↩︎"))
@@ -836,8 +834,7 @@ function renderImportTable() {
updateImportPageInfo(pages); updateImportPageInfo(pages);
} }
// which library the assigned request writes to - visible in the row, because the // target library of the assigned request, easy to miss inside the dialog
// select inside the search dialog is easy to miss
function libraryCell(item, i) { function libraryCell(item, i) {
const req = requestById(item.request_id); const req = requestById(item.request_id);
if (!req) return '<span class="muted">—</span>'; if (!req) return '<span class="muted">—</span>';
@@ -899,8 +896,7 @@ $("#import-select-all").addEventListener("click", () => {
selectIndices(viewIdx, "insgesamt"); selectIndices(viewIdx, "insgesamt");
}); });
// Entries without a suggestion are selected too - they are exactly the ones // entries without a suggestion are included, "Anfragen aus Ordnernamen" needs them
// "Anfragen aus Ordnernamen" works on.
function selectIndices(indices, what) { function selectIndices(indices, what) {
scanItems.forEach((it) => (it.checked = false)); // "take what I see", not "add" scanItems.forEach((it) => (it.checked = false)); // "take what I see", not "add"
indices.forEach((i) => { indices.forEach((i) => {
@@ -924,8 +920,7 @@ $("#import-deselect-all").addEventListener("click", () => {
}); });
// ---- create requests straight from folder names ---- // ---- create requests straight from folder names ----
// For series that Audible only knows in part (Lady Bedfort: 34 of 117) the // for series Audible only knows in part, the folder names carry number and title
// folder names are the better source - they carry number and title already.
let namesTargets = []; let namesTargets = [];
function nameToRequest(item) { function nameToRequest(item) {
@@ -982,8 +977,7 @@ $("#names-submit").addEventListener("click", async () => {
method: "POST", method: "POST",
body: JSON.stringify({ library_id: parseInt(libId), items }), body: JSON.stringify({ library_id: parseInt(libId), items }),
}); });
// reload so that entries skipped as duplicates can be linked to the request // reload so duplicates can be linked to the existing request
// that already existed
missingReqs = await api("/api/requests?status=missing"); missingReqs = await api("/api/requests?status=missing");
const key = (t, v) => `${(t || "").trim().toLowerCase()}|${v ?? ""}`; const key = (t, v) => `${(t || "").trim().toLowerCase()}|${v ?? ""}`;
const byKey = new Map(missingReqs.map((r) => [key(r.title, r.volume), r])); 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 ---- // ---- 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) { function commonPrefix(names) {
let prefix = names[0]; let prefix = names[0];
for (const n of names.slice(1)) { 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++; while (i < prefix.length && i < n.length && prefix[i] === n[i]) i++;
prefix = prefix.slice(0, 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(); 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); toast("Die Einträge liegen in verschiedenen Ordnern", true);
return; return;
} }
// parts may share no prefix at all ("A - Sphinx", "B - Volk"): then the // "A - Sphinx", "B - Volk" share no prefix, then the folder carries the title
// folder holding them carries the real title ("100 - Toteninsel")
const parentName = (chosen[0].rel_dir || "").split("/").filter(Boolean).pop(); const parentName = (chosen[0].rel_dir || "").split("/").filter(Boolean).pop();
const name = const name =
commonPrefix(chosen.map((it) => it.name)) || parentName || chosen[0].name; commonPrefix(chosen.map((it) => it.name)) || parentName || chosen[0].name;
@@ -1071,8 +1064,7 @@ function splitItem(i) {
if (item.parts) { // undo a merge if (item.parts) { // undo a merge
scanItems.splice(i, 1, ...item.parts); scanItems.splice(i, 1, ...item.parts);
} else if (item.is_dir && item.files.length > 1) { } else if (item.is_dir && item.files.length > 1) {
// a folder holding one book per file (a series bought as single titles): // one book per file: every file becomes its own entry
// turn every file into its own entry so each can get its own request
const parts = item.files.map((f) => ({ const parts = item.files.map((f) => ({
path: f, path: f,
name: f.split("/").pop().replace(/\.[^.]+$/, ""), name: f.split("/").pop().replace(/\.[^.]+$/, ""),
@@ -1101,8 +1093,7 @@ let quickAutoSearch = false; // the search fired on open, not by the user
let lastSeries = ""; let lastSeries = "";
// "017 - Titel", "Folge 17: Titel", "[003] Titel" -> episode number + rest. // "017 - Titel", "Folge 17: Titel", "[003] Titel" -> episode number + rest.
// A bare number without a separator ("100 Stunden") or without a leading zero is // A bare number without separator or leading zero stays part of the title.
// left alone: there it is part of the title, not a numbering.
const EPISODE_PATTERNS = [ const EPISODE_PATTERNS = [
/^\s*(?:folge|teil|band|nr\.?)\s*(\d{1,4})\s*(?:[-–—._:]+\s*|\s+)/i, // "Folge 124: Titel" /^\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" /^\s*[\[(](\d{1,4})[\])]\s*(?:[-–—._:]+\s*|\s+)/, // "[003] Titel"
@@ -1132,8 +1123,7 @@ function cleanFileName(name) {
function openQuickDialog(i) { function openQuickDialog(i) {
quickItemIndex = i; quickItemIndex = i;
const item = scanItems[i]; const item = scanItems[i];
// the episode number wrecks the Audible keyword search, so it is kept out of // the number wrecks the Audible search, so it becomes the volume instead
// the query and used as the volume instead
const { number, rest } = splitEpisodeNumber(item.name); const { number, rest } = splitEpisodeNumber(item.name);
quickEpisode = number; quickEpisode = number;
$("#quick-file").textContent = (item.rel_dir ? item.rel_dir + "/" : "") + item.name; $("#quick-file").textContent = (item.rel_dir ? item.rel_dir + "/" : "") + item.name;
@@ -1157,9 +1147,8 @@ function openQuickDialog(i) {
runQuickSearch(); runQuickSearch();
} }
// offer linking to an already-open request instead of creating a duplicate. // link to an open request instead of creating a duplicate: guesses from the
// Without a filter these are the best guesses from the file name; typing turns // file name, or a search over all open requests once something is typed
// the box into a search over every open request, because the guess can be wrong.
const QUICK_OPEN_LIMIT = 20; const QUICK_OPEN_LIMIT = 20;
function renderQuickOpenRequests(item) { function renderQuickOpenRequests(item) {
@@ -1216,8 +1205,7 @@ async function runQuickSearch() {
const box = $("#quick-results"); const box = $("#quick-results");
box.innerHTML = "<p class='muted'>Suche läuft…</p>"; box.innerHTML = "<p class='muted'>Suche läuft…</p>";
try { try {
// the target library decides the language: an "english" library should not // an "english" library should not offer the German edition
// offer the German edition of the same book
const lib = libraries.find((l) => String(l.id) === $("#quick-library").value); const lib = libraries.find((l) => String(l.id) === $("#quick-library").value);
const results = await api( const results = await api(
`/api/search?media_type=${item.media_type}&q=${encodeURIComponent($("#quick-q").value)}` + `/api/search?media_type=${item.media_type}&q=${encodeURIComponent($("#quick-q").value)}` +
@@ -1228,7 +1216,7 @@ async function runQuickSearch() {
box.innerHTML = `<p class='muted'>${EMPTY_HINTS[item.media_type]}</p>`; box.innerHTML = `<p class='muted'>${EMPTY_HINTS[item.media_type]}</p>`;
return; return;
} }
// one compact row per hit: cover, title + a single meta line, action right // one compact row per hit
box.innerHTML = results box.innerHTML = results
.map((r, j) => { .map((r, j) => {
const meta = [ const meta = [
@@ -1272,7 +1260,7 @@ $("#quick-search-form").addEventListener("submit", (e) => {
}); });
$("#quick-close").addEventListener("click", () => $("#quick-dialog").close()); $("#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) { async function requestAndConnect(body) {
const libId = $("#quick-library").value; const libId = $("#quick-library").value;
if (!libId) { toast("Erst eine Library für diesen Typ anlegen", true); return; } 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 // take a hit into the fields so it can be corrected first - Audible files
// request is created - Audible files Harry Potter under "Wizarding World", and // Harry Potter under "Wizarding World", which would end up in the folder name
// the series ends up in the folder name
function editBeforeRequest(r) { function editBeforeRequest(r) {
quickPicked = r; quickPicked = r;
$("#quick-m-title").value = r.title || ""; $("#quick-m-title").value = r.title || "";
@@ -1334,13 +1321,11 @@ $("#quick-m-submit").addEventListener("click", () => {
// ---- import execution (batched, with progress) ---- // ---- import execution (batched, with progress) ----
const IMPORT_BATCH_SIZE = 20; const IMPORT_BATCH_SIZE = 20;
// finished rows disappear after a while: the result list below keeps the record, // finished rows disappear after a while, the result list keeps the record
// and a shrinking table makes the remaining work obvious
const IMPORT_DONE_CLEANUP_MS = 15000; const IMPORT_DONE_CLEANUP_MS = 15000;
let cleanupTimer = null; let cleanupTimer = null;
// several entries on one request means one audiobook split across folders: // several entries on one request: one audiobook split across folders
// importing them one by one only imports the first and fails the rest
function groupByRequest(chosen) { function groupByRequest(chosen) {
const byRequest = new Map(); const byRequest = new Map();
for (const item of chosen) { 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 // either a multi-part title (merge) or a mismatch (skip), decided once
// mismatch (skip them). The user decides once, then keeps the rest importable.
function askAboutSharedRequests(shared) { function askAboutSharedRequests(shared) {
const dlg = $("#conflict-dialog"); const dlg = $("#conflict-dialog");
$("#conflict-intro").textContent = $("#conflict-intro").textContent =
@@ -1402,7 +1386,7 @@ async function collapseSharedRequests(chosen) {
if (answer === "cancel") return null; if (answer === "cancel") return null;
if (answer === "merge") return buildImportGroups(groups); 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(); const skipped = shared.flat();
skipped.forEach((it) => { skipped.forEach((it) => {
it.conflict = true; it.conflict = true;
@@ -1443,11 +1427,10 @@ $("#import-btn").addEventListener("click", async () => {
setImportProgress(0, total); setImportProgress(0, total);
try { try {
// one request per entry: the row can only turn green once its own move came // one request per entry, otherwise a row cannot turn green on its own
// back, and a batch would only report after all of its items finished
for (const { item, parts } of groups) { for (const { item, parts } of groups) {
parts.forEach((p) => (p.importState = "running")); parts.forEach((p) => (p.importState = "running"));
// follow the running entry across pages, otherwise only the bar moves // follow the running entry across pages
applyImportView(); applyImportView();
const pos = viewIdx.indexOf(scanItems.indexOf(parts[0])); const pos = viewIdx.indexOf(scanItems.indexOf(parts[0]));
if (pos >= 0) importPage = Math.floor(pos / IMPORT_PAGE_SIZE); if (pos >= 0) importPage = Math.floor(pos / IMPORT_PAGE_SIZE);
@@ -1493,7 +1476,7 @@ $("#import-btn").addEventListener("click", async () => {
refreshMissingBadge(); refreshMissingBadge();
clearTimeout(cleanupTimer); clearTimeout(cleanupTimer);
cleanupTimer = setTimeout(() => { cleanupTimer = setTimeout(() => {
// only the successful ones go; failures and conflicts stay to be dealt with // failures and conflicts stay
const before = scanItems.length; const before = scanItems.length;
scanItems = scanItems.filter((it) => it.importState !== "done"); scanItems = scanItems.filter((it) => it.importState !== "done");
if (scanItems.length !== before) { 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`; `${scanItems.length} Kandidat(en) übrig — für den aktuellen Stand erneut scannen`;
} }
}, IMPORT_DONE_CLEANUP_MS); }, IMPORT_DONE_CLEANUP_MS);
// the list stays as it is, with its green and red rows - rescanning would // the list stays: rescanning would wipe the feedback
// wipe exactly the feedback the user just waited for
} finally { } finally {
btn.disabled = false; btn.disabled = false;
btn.textContent = "Ausgewählte importieren"; btn.textContent = "Ausgewählte importieren";
+4 -7
View File
@@ -57,8 +57,7 @@ button:disabled { opacity: 0.6; cursor: default; }
.tag.warn { background: #8a6d1f; } .tag.warn { background: #8a6d1f; }
/* per-entry import feedback */ /* per-entry import feedback */
/* :not([hidden]) matters: a plain "display: flex" would beat the hidden /* :not([hidden]): a plain display rule would beat the hidden attribute */
attribute and leave an empty bar sitting there (same trap as a <dialog>) */
#import-progress:not([hidden]) { display: flex; align-items: center; gap: 0.6rem; flex: 1; } #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; } #import-progress progress { flex: 1; max-width: 26rem; height: 0.7rem; }
tr.row-running { background: rgba(90, 140, 220, 0.12); } tr.row-running { background: rgba(90, 140, 220, 0.12); }
@@ -122,10 +121,8 @@ dialog label { display: flex; flex-direction: column; gap: 0.2rem; margin: 0.5re
.series-list input[type=checkbox] { flex: none; } .series-list input[type=checkbox] { flex: none; }
.series-list .num { flex: none; min-width: 3.2em; color: var(--muted); font-variant-numeric: tabular-nums; } .series-list .num { flex: none; min-width: 3.2em; color: var(--muted); font-variant-numeric: tabular-nums; }
.series-list .gap { padding: 0.35rem 0.6rem; color: var(--muted); font-size: 0.85rem; } .series-list .gap { padding: 0.35rem 0.6rem; color: var(--muted); font-size: 0.85rem; }
/* the dialog keeps head and foot in place; only the results scroll, so the /* head and foot stay in place, only the results scroll */
"manuell anlegen" panel stays reachable no matter how many hits came back */ /* [open]: a bare display rule would leave the closed dialog on screen */
/* [open] matters: a bare "#quick-dialog { display: flex }" would override the
browser's display:none for a closed dialog and leave it stuck on screen */
#quick-dialog[open] { display: flex; flex-direction: column; } #quick-dialog[open] { display: flex; flex-direction: column; }
#quick-dialog { max-height: 85vh; width: min(680px, 92vw); } #quick-dialog { max-height: 85vh; width: min(680px, 92vw); }
#quick-scroll { flex: 1 1 auto; min-height: 4rem; overflow-y: auto; margin-top: 0.6rem; } #quick-scroll { flex: 1 1 auto; min-height: 4rem; overflow-y: auto; margin-top: 0.6rem; }
@@ -140,7 +137,7 @@ dialog label { display: flex; flex-direction: column; gap: 0.2rem; margin: 0.5re
#quick-search-form { gap: 0.5rem; } #quick-search-form { gap: 0.5rem; }
#quick-q { flex: 1 1 auto; min-width: 0; } #quick-q { flex: 1 1 auto; min-width: 0; }
#quick-file { word-break: break-word; } #quick-file { word-break: break-word; }
/* one row per hit: cover | title + meta line | action */ /* one row per hit */
#quick-results .card { align-items: center; gap: 0.7rem; padding: 0.45rem 0.6rem; } #quick-results .card { align-items: center; gap: 0.7rem; padding: 0.45rem 0.6rem; }
#quick-results .card-body { flex: 1 1 auto; gap: 0.1rem; } #quick-results .card-body { flex: 1 1 auto; gap: 0.1rem; }
#quick-results .card-body strong { line-height: 1.25; } #quick-results .card-body strong { line-height: 1.25; }
+1 -2
View File
@@ -31,8 +31,7 @@ def calls(monkeypatch):
def test_search_asks_for_the_first_page(calls): def test_search_asks_for_the_first_page(calls):
"""Audible's page parameter is 0-based: page=1 skips the only page of results """Audible's page parameter is 0-based, page=1 skips the only page of hits."""
and a search like "Der Superpapagei" comes back empty."""
results = asyncio.run(audible.search("Der Superpapagei")) results = asyncio.run(audible.search("Der Superpapagei"))
assert [r.title for r in results] == ["Die drei ??? und der Super-Papagei"] assert [r.title for r in results] == ["Die drei ??? und der Super-Papagei"]
assert calls and all(int(c["page"]) == 0 for c in calls) assert calls and all(int(c["page"]) == 0 for c in calls)
+11 -22
View File
@@ -316,8 +316,7 @@ def test_natural_track_order_in_flat_folder(client):
def test_real_world_series_tree(client): def test_real_world_series_tree(client):
"""Downloads laid out as Serie/Folgen/<Sammelordner>/<NNN - Titel>/CD/*.mp3, """Serie/Folgen/<Sammelordner>/<NNN - Titel>/CD/*.mp3, next to a Cover folder."""
with a Cover/ folder next to the CD folder."""
base = client.downloads / "Die Drei Fragezeichen" / "Folgen" / "3478632869 001-010" base = client.downloads / "Die Drei Fragezeichen" / "Folgen" / "3478632869 001-010"
episodes = ["001 - Der Super - Papagei", "002 - Der Phantomsee", "003 - Der Karpatenhund"] episodes = ["001 - Der Super - Papagei", "002 - Der Phantomsee", "003 - Der Karpatenhund"]
for ep in episodes: for ep in episodes:
@@ -419,8 +418,7 @@ def test_bulk_items_series_then_scan_matches_by_episode_number(client):
def test_merged_multipart_import(client): def test_merged_multipart_import(client):
"""A multi-part episode ("Teil 1/2/3") imported as one audiobook: the client """A multi-part episode ("Teil 1/2/3") imported as one audiobook."""
merges the scanned entries and posts their files as a single item."""
root = client.tmp_path / "library" / "ddf" root = client.tmp_path / "library" / "ddf"
lib_id = client.post("/api/libraries", json={ lib_id = client.post("/api/libraries", json={
"name": "DDF", "media_type": "audiobook", "root_path": str(root), "name": "DDF", "media_type": "audiobook", "root_path": str(root),
@@ -484,8 +482,7 @@ def test_import_rejects_files_outside_download_dir(client):
def test_merge_at_download_root_keeps_the_download_dir(client): def test_merge_at_download_root_keeps_the_download_dir(client):
"""Merging entries that sit directly in the download dir makes the item path """Merging at the download root must not remove the download dir."""
the download dir itself - which must never be removed."""
lib_id = client.post("/api/libraries", json={ lib_id = client.post("/api/libraries", json={
"name": "L", "media_type": "audiobook", "root_path": str(client.tmp_path / "l"), "name": "L", "media_type": "audiobook", "root_path": str(client.tmp_path / "l"),
}).json()["id"] }).json()["id"]
@@ -509,8 +506,7 @@ def test_merge_at_download_root_keeps_the_download_dir(client):
def test_merged_parts_clean_up_nested_source_folders(client): def test_merged_parts_clean_up_nested_source_folders(client):
""""100 - Toteninsel/A - Sphinx/CD/*.mp3" - every emptied level below the """Every emptied level below a merged item goes away, the cover folder stays."""
merged item goes away, the cover folder keeps its parent alive."""
lib_id = client.post("/api/libraries", json={ lib_id = client.post("/api/libraries", json={
"name": "L", "media_type": "audiobook", "root_path": str(client.tmp_path / "l"), "name": "L", "media_type": "audiobook", "root_path": str(client.tmp_path / "l"),
"folder_template": "{Title}", "file_template": "{Title}", "folder_template": "{Title}", "file_template": "{Title}",
@@ -584,8 +580,7 @@ def test_letter_folders_that_are_not_parts_stay_separate(client):
def test_append_to_an_already_imported_audiobook(client): def test_append_to_an_already_imported_audiobook(client):
"""The Toteninsel case: part A got imported, B and C failed. They can be """The Toteninsel case: part A got imported, B and C failed."""
added afterwards without moving anything back by hand."""
root = client.tmp_path / "library" / "kids" root = client.tmp_path / "library" / "kids"
lib_id = client.post("/api/libraries", json={ lib_id = client.post("/api/libraries", json={
"name": "K", "media_type": "audiobook", "root_path": str(root), "name": "K", "media_type": "audiobook", "root_path": str(root),
@@ -692,8 +687,7 @@ def test_search_passes_language_to_the_provider(client, monkeypatch):
def test_folder_of_separate_books_is_flagged(client): def test_folder_of_separate_books_is_flagged(client):
"""One book per file (a series bought as single titles) still scans as one """One book per file scans as one entry, but gets flagged for the UI."""
entry - but gets flagged so the UI can offer to split it."""
got = client.downloads / "George R. R. Martin" / "A Game of Thrones" got = client.downloads / "George R. R. Martin" / "A Game of Thrones"
got.mkdir(parents=True) got.mkdir(parents=True)
for name in ("1 - A Game of Thrones- A Song of Ice and Fire, Book 1 (Unabridged)", for name in ("1 - A Game of Thrones- A Song of Ice and Fire, Book 1 (Unabridged)",
@@ -734,8 +728,7 @@ def test_split_dirs_still_lists_every_file(client):
def test_relocate_after_fixing_the_series(client): def test_relocate_after_fixing_the_series(client):
"""Audible files Harry Potter under "Wizarding World"; after correcting the """After correcting the series the folder on disk follows."""
series the folder on disk should follow."""
root = client.tmp_path / "library" / "english" root = client.tmp_path / "library" / "english"
lib_id = client.post("/api/libraries", json={ lib_id = client.post("/api/libraries", json={
"name": "english", "media_type": "audiobook", "root_path": str(root), "name": "english", "media_type": "audiobook", "root_path": str(root),
@@ -825,8 +818,7 @@ def test_relocate_refuses_to_overwrite(client):
def test_files_moved_back_are_matched_against_imported_requests(client): def test_files_moved_back_are_matched_against_imported_requests(client):
"""Moving a library folder back into the download dir: the request still says """A request whose files left the library is suggested again."""
"imported", but its files are gone - the scan should suggest it anyway."""
root = client.tmp_path / "library" / "kids" root = client.tmp_path / "library" / "kids"
lib_id = client.post("/api/libraries", json={ lib_id = client.post("/api/libraries", json={
"name": "kids", "media_type": "audiobook", "root_path": str(root), "name": "kids", "media_type": "audiobook", "root_path": str(root),
@@ -893,8 +885,7 @@ def test_imported_request_with_files_in_place_is_still_refused(client):
def test_two_titles_rendering_to_the_same_name_do_not_overwrite(client): def test_two_titles_rendering_to_the_same_name_do_not_overwrite(client):
"""Two books whose templates produce the same folder and file names must not """Two titles rendering to the same name must not overwrite each other."""
silently replace each other - that loses files and confuses ABS."""
root = client.tmp_path / "library" / "dcc" root = client.tmp_path / "library" / "dcc"
lib_id = client.post("/api/libraries", json={ lib_id = client.post("/api/libraries", json={
"name": "DCC", "media_type": "audiobook", "root_path": str(root), "name": "DCC", "media_type": "audiobook", "root_path": str(root),
@@ -932,8 +923,7 @@ def test_two_titles_rendering_to_the_same_name_do_not_overwrite(client):
def test_scan_and_import_survive_a_name_that_is_not_utf8(client): def test_scan_and_import_survive_a_name_that_is_not_utf8(client):
"""A single Latin-1 byte in a folder name used to fail the whole scan with a """A single Latin-1 byte in a folder name used to fail the whole scan."""
500 (UnicodeEncodeError: surrogates not allowed)."""
broken = os.fsdecode(b"Die drei ??? - Die Fu\xdfball-Falle") broken = os.fsdecode(b"Die drei ??? - Die Fu\xdfball-Falle")
folder = client.downloads / broken folder = client.downloads / broken
folder.mkdir() folder.mkdir()
@@ -968,8 +958,7 @@ def test_scan_and_import_survive_a_name_that_is_not_utf8(client):
def test_loose_files_in_the_download_root_do_not_swallow_everything(client): def test_loose_files_in_the_download_root_do_not_swallow_everything(client):
"""Two loose audio files directly in the download dir used to make the whole """Loose files in the download root must not hide the subfolders."""
root look like one audiobook - and its early return hid every subfolder."""
(client.downloads / "Teil 01.mp3").write_bytes(b"") (client.downloads / "Teil 01.mp3").write_bytes(b"")
(client.downloads / "Teil 02.mp3").write_bytes(b"") (client.downloads / "Teil 02.mp3").write_bytes(b"")
for folge in ("Folge 1", "Folge 2"): for folge in ("Folge 1", "Folge 2"):
+1 -2
View File
@@ -62,8 +62,7 @@ def test_missing_values_do_not_leave_dangling_separators():
def test_narrator_uses_the_first_name_only(): def test_narrator_uses_the_first_name_only():
"""Two editions of one book differ by narrator; a full cast must not blow up """A full cast must not blow up the folder name."""
the folder name."""
fry = SimpleNamespace(title="Philosopher's Stone", authors="J.K. Rowling", fry = SimpleNamespace(title="Philosopher's Stone", authors="J.K. Rowling",
series="Harry Potter", volume=1, year=None, series="Harry Potter", volume=1, year=None,
narrator="Stephen Fry") narrator="Stephen Fry")
+1 -2
View File
@@ -78,8 +78,7 @@ def test_dialogs_start_closed(page):
def test_hidden_elements_really_are_hidden(page): def test_hidden_elements_really_are_hidden(page):
"""A CSS display rule can override the hidden attribute - it did for the """A CSS display rule can override the hidden attribute."""
dialog and again for the progress bar."""
page.click("nav button[data-view=import]") page.click("nav button[data-view=import]")
for sel in ("#import-progress", "#import-btn", "#import-table", "#missing-badge"): for sel in ("#import-progress", "#import-btn", "#import-table", "#missing-badge"):
assert not page.locator(sel).is_visible(), sel assert not page.locator(sel).is_visible(), sel
+2 -4
View File
@@ -82,16 +82,14 @@ def do_import(data: ImportIn, session: Session = Depends(get_session)):
results.append({"path": shown, "ok": True, "dest": dest, "appended": True}) results.append({"path": shown, "ok": True, "dest": dest, "appended": True})
continue continue
elif req.status != "missing": elif req.status != "missing":
# usually several entries pointing at the same request: the first one # usually several entries pointing at the same request
# imported and flipped it, the rest land here
results.append({"path": shown, "ok": False, "error": ( results.append({"path": shown, "ok": False, "error": (
f"Anfrage „{req.title}“ (#{req.id}) ist bereits importiert" f"Anfrage „{req.title}“ (#{req.id}) ist bereits importiert"
f"{' nach ' + req.imported_path if req.imported_path else ''}" f"{' nach ' + req.imported_path if req.imported_path else ''}"
" — mehrere Einträge auf dieselbe Anfrage? Dann vorher zusammenfassen." " — mehrere Einträge auf dieselbe Anfrage? Dann vorher zusammenfassen."
)}) )})
continue continue
# every source path must stay inside the download dir - merged items # merged items carry files from several folders, so check them all
# carry files from several folders, so check them all
paths = [Path(p).resolve() for p in [item.path, *item.files]] paths = [Path(p).resolve() for p in [item.path, *item.files]]
if any(download_root not in p.parents and p != download_root for p in paths): if any(download_root not in p.parents and p != download_root for p in paths):
results.append({"path": shown, "ok": False, "error": "path outside download dir"}) results.append({"path": shown, "ok": False, "error": "path outside download dir"})
+1 -2
View File
@@ -19,8 +19,7 @@ class Library(Base):
root_path: Mapped[str] = mapped_column(String) root_path: Mapped[str] = mapped_column(String)
folder_template: Mapped[str] = mapped_column(String) folder_template: Mapped[str] = mapped_column(String)
file_template: Mapped[str] = mapped_column(String) file_template: Mapped[str] = mapped_column(String)
# audible language of this library's editions ("english", "german", …). # "english", "german", … - empty means no restriction
# empty means no restriction.
language: Mapped[str] = mapped_column(String, default="") language: Mapped[str] = mapped_column(String, default="")
requests: Mapped[list["BookRequest"]] = relationship(back_populates="library") requests: Mapped[list["BookRequest"]] = relationship(back_populates="library")
+3 -5
View File
@@ -1,10 +1,8 @@
"""Carrying file names that are not valid UTF-8 through the JSON API. """Carrying file names that are not valid UTF-8 through the JSON API.
Linux allows any byte in a name, so an old rip can hold a Latin-1 "ü". Python Linux allows any byte in a name, and Python decodes those with surrogateescape,
decodes such names with surrogateescape ("\\udcfc"), and those characters cannot which JSON cannot encode. Display names are cleaned up, paths keep a lossless
be encoded as UTF-8 - a single one of them used to make the whole scan fail with representation so the import still finds the file.
a 500. Display names are cleaned up, while paths keep a lossless representation
so the import still finds the file.
""" """
import base64 import base64
+4 -9
View File
@@ -8,8 +8,7 @@ LEET = {"0": "o", "1": "i", "3": "e", "4": "a", "5": "s", "7": "t"}
def _deleet(s: str) -> str: def _deleet(s: str) -> str:
"""Undo leetspeak for digits enclosed by letters ("dr3i" -> "drei"). Digits at """"dr3i" -> "drei". Digits at a word edge stay, those are episode numbers."""
a word edge stay ("folge 03", "folge03") - those are volume/episode numbers."""
return re.sub(r"(?<=[a-z])([013457])(?=[a-z])", return re.sub(r"(?<=[a-z])([013457])(?=[a-z])",
lambda m: LEET[m.group(1)], s) lambda m: LEET[m.group(1)], s)
@@ -38,10 +37,7 @@ def score(item_name: str, request, rel_dir: str = "") -> float:
s = fuzz.token_set_ratio(name, target) s = fuzz.token_set_ratio(name, target)
title_only = fuzz.token_set_ratio(name, normalize(request.title)) title_only = fuzz.token_set_ratio(name, normalize(request.title))
result = max(s, title_only * 0.95) result = max(s, title_only * 0.95)
# for numbered volumes/episodes the number must match, otherwise every # the narrator disambiguates otherwise identical titles
# episode of a series scores alike on the shared title tokens
# narrator in the file name (e.g. "gelesen von Rufus Beck") disambiguates
# otherwise identical titles
narrator = getattr(request, "narrator", "") or "" narrator = getattr(request, "narrator", "") or ""
if narrator and fuzz.partial_ratio(normalize(narrator), name) > 80: if narrator and fuzz.partial_ratio(normalize(narrator), name) > 80:
result = min(100.0, result + 5) result = min(100.0, result + 5)
@@ -51,8 +47,7 @@ def score(item_name: str, request, rel_dir: str = "") -> float:
result = min(100.0, result + 5) result = min(100.0, result + 5)
elif nums: elif nums:
result = min(result, 40.0) result = min(result, 40.0)
# series in the folder path + matching episode number is a strong signal # series in the path plus a matching number beats a weak title match
# even when the file name carries only the episode title ("001 - Titel")
series = getattr(request, "series", "") or "" series = getattr(request, "series", "") or ""
if series and request.volume in nums: if series and request.volume in nums:
context = normalize(f"{rel_dir} {item_name}") context = normalize(f"{rel_dir} {item_name}")
@@ -62,7 +57,7 @@ def score(item_name: str, request, rel_dir: str = "") -> float:
def best_matches(items: list[dict], requests) -> list[dict]: def best_matches(items: list[dict], requests) -> list[dict]:
"""For each scanned item, attach the best-scoring open request of the same media type.""" """Attach the best-scoring open request of the same media type to each item."""
out = [] out = []
for item in items: for item in items:
candidates = [r for r in requests if r.media_type == item["media_type"]] candidates = [r for r in requests if r.media_type == item["media_type"]]
+8 -13
View File
@@ -7,10 +7,8 @@ from . import tagger
def _cleanup_sources(src_dir: Path, paths: list[Path]) -> None: def _cleanup_sources(src_dir: Path, paths: list[Path]) -> None:
"""Remove emptied source folders: the item itself plus every folder the files """Remove emptied source folders, deepest first. Folders that still hold
came from, deepest first - "…/100 - Toteninsel/A - Sphinx/CD" contributes all files and the download dir itself are left alone."""
three. Anything still holding files (cover art, booklets) is left alone, and
the download dir itself is never removed."""
download_root = Path(config.DOWNLOAD_DIR).resolve() download_root = Path(config.DOWNLOAD_DIR).resolve()
candidates = {src_dir} candidates = {src_dir}
for p in paths: for p in paths:
@@ -42,9 +40,8 @@ def import_item(item_path: str, files: list[str], is_dir: bool, request, library
folder / sanitize(f"{base} - Part {i:0{width}d}{src.suffix.lower()}") folder / sanitize(f"{base} - Part {i:0{width}d}{src.suffix.lower()}")
for i, src in enumerate(paths, 1) for i, src in enumerate(paths, 1)
] ]
# check every target first: moving file by file would leave a half # check every target first: otherwise a second title with the same
# imported folder behind, and without the check a second title rendering # rendered name would silently overwrite the first one
# to the same name would silently overwrite the first one
taken = [d for d in targets if d.exists()] taken = [d for d in targets if d.exists()]
if taken: if taken:
raise FileExistsError( raise FileExistsError(
@@ -70,9 +67,8 @@ def import_item(item_path: str, files: list[str], is_dir: bool, request, library
def append_to_import(files: list[str], request, library) -> str: def append_to_import(files: list[str], request, library) -> str:
"""Add more files to an audiobook that was already imported - a part that """Add files to an already imported audiobook. Numbering continues after
arrived late, or one that failed on the first run. Numbering continues after what is there, and all tracks are re-tagged with the new total."""
the files already there, and all tracks are re-tagged with the new total."""
dest_dir = Path(request.imported_path or "") dest_dir = Path(request.imported_path or "")
if not dest_dir.is_dir(): if not dest_dir.is_dir():
raise FileNotFoundError( raise FileNotFoundError(
@@ -103,9 +99,8 @@ def append_to_import(files: list[str], request, library) -> str:
def relocate(request, library) -> str: def relocate(request, library) -> str:
"""Move an already imported audiobook to where the library's templates say it """Move an imported audiobook to where the templates say it belongs now.
belongs now - after correcting series, title or author. Files are renamed as Extras move along, emptied folders are removed."""
on import, extras (cover art) move along, and emptied folders are removed."""
src = Path(request.imported_path or "") src = Path(request.imported_path or "")
if not src.exists(): if not src.exists():
raise FileNotFoundError(f"Pfad existiert nicht (mehr): {request.imported_path}") raise FileNotFoundError(f"Pfad existiert nicht (mehr): {request.imported_path}")
+16 -29
View File
@@ -4,20 +4,17 @@ from pathlib import Path
from .. import config from .. import config
from ..fsnames import encode_path, readable from ..fsnames import encode_path, readable
# "CD", "CD1", "CD 2", "Disc_03", "Teil 1", "Teil B" as folder name. The index is # "CD", "CD1", "Disc_03", "Teil B" - a bare number is not a disc, those folders
# optional (single-disc rips just use "CD") and may be a letter. A bare number is # are usually episodes of a series.
# deliberately not a disc: those folders are usually episodes of a series.
DISC_DIR_RE = re.compile( DISC_DIR_RE = re.compile(
r"^(?:cd|disc|disk|dvd|teil|part|vol|volume)[\s._-]*(\d{1,3}|[a-h])?$", r"^(?:cd|disc|disk|dvd|teil|part|vol|volume)[\s._-]*(\d{1,3}|[a-h])?$",
re.IGNORECASE, re.IGNORECASE,
) )
# "A - Das Raetsel der Sphinx", "B - Das vergessene Volk": parts of one story, # "A - Titel", "B - Titel": parts of one story, used only when every audio
# told apart only by a leading letter. Only used when *every* audio subfolder # subfolder follows the pattern.
# follows the pattern.
LETTER_PART_RE = re.compile(r"^([a-h])\s*[-–—._:]\s*\S", re.IGNORECASE) LETTER_PART_RE = re.compile(r"^([a-h])\s*[-–—._:]\s*\S", re.IGNORECASE)
# leading track/part index of a file name: "01 - ", "1. ", "[A] ", "B - ", "01 ". # leading track index: digits or a single letter, so "Der Hobbit …" keeps its
# Only digits or a single letter count, so a title starting with a short word # first word
# ("Der Hobbit …") keeps its first word.
LEADING_INDEX_RE = re.compile( LEADING_INDEX_RE = re.compile(
r"^\s*[\[(]?(?:\d{1,4}|[a-z])[\])]?\s*(?:[-–—._:]+\s*|\s+)", re.IGNORECASE r"^\s*[\[(]?(?:\d{1,4}|[a-z])[\])]?\s*(?:[-–—._:]+\s*|\s+)", re.IGNORECASE
) )
@@ -46,8 +43,7 @@ def _index(token: str) -> int:
def disc_number(name: str) -> int | None: def disc_number(name: str) -> int | None:
"""Disc index of a disc folder name, 0 for an unnumbered "CD", 1-8 for a """Disc index, 0 for an unnumbered "CD". None if it is not a disc folder."""
letter ("Teil B"). None if the name is not a disc folder at all."""
m = DISC_DIR_RE.match(name.strip()) m = DISC_DIR_RE.match(name.strip())
if not m: if not m:
return None return None
@@ -61,10 +57,8 @@ def letter_part(name: str) -> int | None:
def separate_titles(files: list[Path]) -> bool: def separate_titles(files: list[Path]) -> bool:
"""True when a folder's audio files look like distinct books rather than the """True when the files look like separate books: names differ beyond the
parts of one audiobook: their names differ beyond a leading index, and each index and each is big enough. A hint for the UI, the split stays manual."""
file is large enough to be a whole book. Only a hint for the UI - the split
stays a manual decision, because chapter-per-file rips look similar."""
residuals = {LEADING_INDEX_RE.sub("", f.stem).strip().lower() for f in files} residuals = {LEADING_INDEX_RE.sub("", f.stem).strip().lower() for f in files}
if len(residuals) < 2: if len(residuals) < 2:
return False return False
@@ -75,11 +69,8 @@ def separate_titles(files: list[Path]) -> bool:
def scan(root: Path, split_dirs: bool = False) -> list[dict]: def scan(root: Path, split_dirs: bool = False) -> list[dict]:
"""Scan the download dir. Returns items: single files, or a directory that is """Scan the download dir for single files and audiobook folders.
an audiobook unit (contains >1 audio file, or disc subfolders like CD1/CD2, With split_dirs=True every file is listed on its own."""
and nothing but audio/junk).
With split_dirs=True every file is listed individually (e.g. for folders
holding many episodes of a series)."""
items = [] items = []
if not root.is_dir(): if not root.is_dir():
return items return items
@@ -101,15 +92,12 @@ def scan(root: Path, split_dirs: bool = False) -> list[dict]:
) )
def disc_audio(d: Path) -> list[Path] | None: def disc_audio(d: Path) -> list[Path] | None:
"""Audio files of d's disc subfolders ("CD", "CD1", "CD2", …), in disc """Audio files of d's disc subfolders in disc order, None if d is not
order. Subfolders without audio (Cover, Scans, …) are ignored. None if d split into discs. Subfolders without audio are ignored."""
is not a disc-split folder."""
audio_subs = [s for s in subdirs(d) if has_audio(s)] audio_subs = [s for s in subdirs(d) if has_audio(s)]
# artwork/booklet folders are already filtered out above
discs = [(disc_number(s.name), s) for s in audio_subs] discs = [(disc_number(s.name), s) for s in audio_subs]
if any(num is None for num, _ in discs): if any(num is None for num, _ in discs):
# not disc folders - but "A - …", "B - …" are parts of one story too, # "A - …", "B - …" are parts of one story too
# as long as every single one of them follows that shape
letters = [(letter_part(s.name), s) for s in audio_subs] letters = [(letter_part(s.name), s) for s in audio_subs]
if len(letters) < 2 or any(num is None for num, _ in letters): if len(letters) < 2 or any(num is None for num, _ in letters):
return None # real subfolders -> walk normally return None # real subfolders -> walk normally
@@ -140,9 +128,8 @@ def scan(root: Path, split_dirs: bool = False) -> list[dict]:
"maybe_separate": False, # disc folders are one book by definition "maybe_separate": False, # disc folders are one book by definition
}) })
return return
# the download dir itself is a collection, never one audiobook: # the download dir is a collection, never one audiobook: it would
# a couple of loose files in it would otherwise swallow every # swallow every subfolder, because this branch returns early
# subfolder, because this branch returns early
if len(audio) > 1 and d != root: if len(audio) > 1 and d != root:
items.append({ items.append({
"path": encode_path(str(d)), "path": encode_path(str(d)),
+7 -11
View File
@@ -7,34 +7,30 @@ from .base import MetadataResult
# Comma-separated marketplace TLDs, first entries rank first in the results. # Comma-separated marketplace TLDs, first entries rank first in the results.
REGIONS = [r.strip() for r in os.environ.get("WORDARR_AUDIBLE_REGIONS", "de,com").split(",")] REGIONS = [r.strip() for r in os.environ.get("WORDARR_AUDIBLE_REGIONS", "de,com").split(",")]
# a marketplace ranks its own language first, so searching for an English # a marketplace ranks its own language first
# edition works much better on .com than on .de - and vice versa
LANGUAGE_REGIONS = {"english": ["com", "co.uk"], "german": ["de"]} LANGUAGE_REGIONS = {"english": ["com", "co.uk"], "german": ["de"]}
RESPONSE_GROUPS = "media,contributors,product_desc,product_attrs,series" RESPONSE_GROUPS = "media,contributors,product_desc,product_attrs,series"
PAGE_SIZE = 50 PAGE_SIZE = 50
# Audible has no "list a series" endpoint (series_asin is rejected), so a series # Audible has no "list a series" endpoint, so a series is collected by paging
# is collected by paging through keyword searches and keeping the products that # through keyword searches; one query never returns the whole catalog.
# carry its asin. One query never returns the whole catalog, hence the variants.
SERIES_QUERY_SUFFIXES = ["", " und der", " und die", " und das", " Folge"] SERIES_QUERY_SUFFIXES = ["", " und der", " und die", " und das", " Folge"]
SERIES_MAX_PAGES = 12 SERIES_MAX_PAGES = 12
SERIES_MAX_CALLS = 90 SERIES_MAX_CALLS = 90
# Audible drops requests when hit with a dozen at once, so pages go out in chunks # Audible drops requests when hit with a dozen at once
SERIES_CHUNK = 4 SERIES_CHUNK = 4
async def _query(client: httpx.AsyncClient, tld: str, query: str, num_results: int = 10, async def _query(client: httpx.AsyncClient, tld: str, query: str, num_results: int = 10,
page: int = 0, sort: str | None = "Relevance") -> list[dict]: page: int = 0, sort: str | None = "Relevance") -> list[dict]:
# NB: page is 0-based - page=1 is the *second* page and comes back empty for # NB: page is 0-based, page=1 is the second one
# any query with fewer than num_results hits.
params = { params = {
"keywords": query, "keywords": query,
"num_results": num_results, "num_results": num_results,
"page": page, "page": page,
"response_groups": RESPONSE_GROUPS, "response_groups": RESPONSE_GROUPS,
} }
# sorting by relevance helps the top-10 search but measurably shrinks what a # relevance helps the top-10 search but shrinks a deep paged sweep
# deep paged sweep returns, so the series crawl leaves it off
if sort: if sort:
params["products_sort_by"] = sort params["products_sort_by"] = sort
try: try:
@@ -86,7 +82,7 @@ async def search(query: str, language: str = "") -> list[MetadataResult]:
seen_asins.add(asin) seen_asins.add(asin)
results.append(_to_result(p)) results.append(_to_result(p))
if language: if language:
# keep entries whose language is unknown, drop the ones we know differ # keep unknown languages, drop the ones we know differ
results = [r for r in results if r.language in ("", language)] results = [r for r in results if r.language in ("", language)]
return results return results
+6 -9
View File
@@ -1,9 +1,8 @@
"""MusicBrainz as a fallback for audio dramas Audible does not carry. """MusicBrainz, for audio dramas Audible does not carry.
German Hörspiel series are well covered there, often with the episode number in Often has the episode number in the title and the original release date instead
the title and the *original* release date instead of a later Audible reissue. of a later reissue. One request per second and a descriptive user agent are
The service asks for one request per second and a descriptive user agent; both required, which makes it slower than Audible.
are honoured here, which makes it deliberately slower than Audible.
""" """
import asyncio import asyncio
@@ -86,8 +85,7 @@ LUCENE_SPECIAL = re.compile(r'[+\-&|!(){}\[\]^"~*?:\\/]+')
def _terms(query: str) -> str: def _terms(query: str) -> str:
"""Words joined with AND - a quoted phrase would miss "Die drei ??? 100: """Words joined with AND; a quoted phrase would miss "… 100: Toteninsel"."""
Toteninsel" when searching for "Die drei ??? Toteninsel"."""
words = [w for w in LUCENE_SPECIAL.sub(" ", query).split() if len(w) > 1][:8] words = [w for w in LUCENE_SPECIAL.sub(" ", query).split() if len(w) > 1][:8]
return " AND ".join(f"release:{w}" for w in words) return " AND ".join(f"release:{w}" for w in words)
@@ -102,8 +100,7 @@ async def search(query: str, language: str = "") -> list[MetadataResult]:
async with httpx.AsyncClient(timeout=25, follow_redirects=True) as client: async with httpx.AsyncClient(timeout=25, follow_redirects=True) as client:
data = await _get(client, " AND ".join(terms)) data = await _get(client, " AND ".join(terms))
# the same audio drama exists as many pressings; keep the oldest of each, # many pressings per drama, keep the oldest: the original release
# that is the original release rather than a reissue
best: dict[tuple, MetadataResult] = {} best: dict[tuple, MetadataResult] = {}
for release in data.get("releases", []): for release in data.get("releases", []):
result = _to_result(release) result = _to_result(release)
+2 -4
View File
@@ -1,7 +1,6 @@
import re import re
# only path separators and control chars are truly forbidden on Linux; keep # keep everything but path separators, so "Die drei ???" survives as-is
# everything else literal so names like "Die drei ???" survive as-is
_FORBIDDEN = re.compile(r"[/\\\x00-\x1f]") _FORBIDDEN = re.compile(r"[/\\\x00-\x1f]")
@@ -15,8 +14,7 @@ def render_template(template: str, request) -> str:
"""Render a naming template against a BookRequest. Each path segment is sanitized.""" """Render a naming template against a BookRequest. Each path segment is sanitized."""
author = (request.authors or "").split(",")[0].strip() or "Unknown Author" author = (request.authors or "").split(",")[0].strip() or "Unknown Author"
narrators = getattr(request, "narrator", "") or "" narrators = getattr(request, "narrator", "") or ""
# like {Author}: the first name only, so a full-cast production does not turn # first name only, a full cast would blow up the folder name
# into a 200 character folder name
narrator = narrators.split(",")[0].strip() narrator = narrators.split(",")[0].strip()
values = { values = {
"Author": author, "Author": author,