docs: shorten the readme and trim code comments
This commit is contained in:
@@ -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.
|
||||
|
||||
+32
-50
@@ -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 '<span class="muted">—</span>';
|
||||
@@ -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 = "<p class='muted'>Suche läuft…</p>";
|
||||
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 = `<p class='muted'>${EMPTY_HINTS[item.media_type]}</p>`;
|
||||
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";
|
||||
|
||||
+4
-7
@@ -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 <dialog>) */
|
||||
/* :not([hidden]): a plain display rule would beat the hidden attribute */
|
||||
#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); }
|
||||
@@ -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 .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; }
|
||||
/* the dialog keeps head and foot in place; only the results scroll, so the
|
||||
"manuell anlegen" panel stays reachable no matter how many hits came back */
|
||||
/* [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 */
|
||||
/* head and foot stay in place, only the results scroll */
|
||||
/* [open]: a bare display rule would leave the closed dialog on screen */
|
||||
#quick-dialog[open] { display: flex; flex-direction: column; }
|
||||
#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; }
|
||||
@@ -140,7 +137,7 @@ dialog label { display: flex; flex-direction: column; gap: 0.2rem; margin: 0.5re
|
||||
#quick-search-form { gap: 0.5rem; }
|
||||
#quick-q { flex: 1 1 auto; min-width: 0; }
|
||||
#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-body { flex: 1 1 auto; gap: 0.1rem; }
|
||||
#quick-results .card-body strong { line-height: 1.25; }
|
||||
|
||||
@@ -31,8 +31,7 @@ def calls(monkeypatch):
|
||||
|
||||
|
||||
def test_search_asks_for_the_first_page(calls):
|
||||
"""Audible's page parameter is 0-based: page=1 skips the only page of results
|
||||
and a search like "Der Superpapagei" comes back empty."""
|
||||
"""Audible's page parameter is 0-based, page=1 skips the only page of hits."""
|
||||
results = asyncio.run(audible.search("Der Superpapagei"))
|
||||
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)
|
||||
|
||||
+11
-22
@@ -316,8 +316,7 @@ def test_natural_track_order_in_flat_folder(client):
|
||||
|
||||
|
||||
def test_real_world_series_tree(client):
|
||||
"""Downloads laid out as Serie/Folgen/<Sammelordner>/<NNN - Titel>/CD/*.mp3,
|
||||
with a Cover/ folder next to the CD folder."""
|
||||
"""Serie/Folgen/<Sammelordner>/<NNN - Titel>/CD/*.mp3, next to a Cover folder."""
|
||||
base = client.downloads / "Die Drei Fragezeichen" / "Folgen" / "3478632869 001-010"
|
||||
episodes = ["001 - Der Super - Papagei", "002 - Der Phantomsee", "003 - Der Karpatenhund"]
|
||||
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):
|
||||
"""A multi-part episode ("Teil 1/2/3") imported as one audiobook: the client
|
||||
merges the scanned entries and posts their files as a single item."""
|
||||
"""A multi-part episode ("Teil 1/2/3") imported as one audiobook."""
|
||||
root = client.tmp_path / "library" / "ddf"
|
||||
lib_id = client.post("/api/libraries", json={
|
||||
"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):
|
||||
"""Merging entries that sit directly in the download dir makes the item path
|
||||
the download dir itself - which must never be removed."""
|
||||
"""Merging at the download root must not remove the download dir."""
|
||||
lib_id = client.post("/api/libraries", json={
|
||||
"name": "L", "media_type": "audiobook", "root_path": str(client.tmp_path / "l"),
|
||||
}).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):
|
||||
""""100 - Toteninsel/A - Sphinx/CD/*.mp3" - every emptied level below the
|
||||
merged item goes away, the cover folder keeps its parent alive."""
|
||||
"""Every emptied level below a merged item goes away, the cover folder stays."""
|
||||
lib_id = client.post("/api/libraries", json={
|
||||
"name": "L", "media_type": "audiobook", "root_path": str(client.tmp_path / "l"),
|
||||
"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):
|
||||
"""The Toteninsel case: part A got imported, B and C failed. They can be
|
||||
added afterwards without moving anything back by hand."""
|
||||
"""The Toteninsel case: part A got imported, B and C failed."""
|
||||
root = client.tmp_path / "library" / "kids"
|
||||
lib_id = client.post("/api/libraries", json={
|
||||
"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):
|
||||
"""One book per file (a series bought as single titles) still scans as one
|
||||
entry - but gets flagged so the UI can offer to split it."""
|
||||
"""One book per file scans as one entry, but gets flagged for the UI."""
|
||||
got = client.downloads / "George R. R. Martin" / "A Game of Thrones"
|
||||
got.mkdir(parents=True)
|
||||
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):
|
||||
"""Audible files Harry Potter under "Wizarding World"; after correcting the
|
||||
series the folder on disk should follow."""
|
||||
"""After correcting the series the folder on disk follows."""
|
||||
root = client.tmp_path / "library" / "english"
|
||||
lib_id = client.post("/api/libraries", json={
|
||||
"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):
|
||||
"""Moving a library folder back into the download dir: the request still says
|
||||
"imported", but its files are gone - the scan should suggest it anyway."""
|
||||
"""A request whose files left the library is suggested again."""
|
||||
root = client.tmp_path / "library" / "kids"
|
||||
lib_id = client.post("/api/libraries", json={
|
||||
"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):
|
||||
"""Two books whose templates produce the same folder and file names must not
|
||||
silently replace each other - that loses files and confuses ABS."""
|
||||
"""Two titles rendering to the same name must not overwrite each other."""
|
||||
root = client.tmp_path / "library" / "dcc"
|
||||
lib_id = client.post("/api/libraries", json={
|
||||
"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):
|
||||
"""A single Latin-1 byte in a folder name used to fail the whole scan with a
|
||||
500 (UnicodeEncodeError: surrogates not allowed)."""
|
||||
"""A single Latin-1 byte in a folder name used to fail the whole scan."""
|
||||
broken = os.fsdecode(b"Die drei ??? - Die Fu\xdfball-Falle")
|
||||
folder = client.downloads / broken
|
||||
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):
|
||||
"""Two loose audio files directly in the download dir used to make the whole
|
||||
root look like one audiobook - and its early return hid every subfolder."""
|
||||
"""Loose files in the download root must not hide the subfolders."""
|
||||
(client.downloads / "Teil 01.mp3").write_bytes(b"")
|
||||
(client.downloads / "Teil 02.mp3").write_bytes(b"")
|
||||
for folge in ("Folge 1", "Folge 2"):
|
||||
|
||||
@@ -62,8 +62,7 @@ def test_missing_values_do_not_leave_dangling_separators():
|
||||
|
||||
|
||||
def test_narrator_uses_the_first_name_only():
|
||||
"""Two editions of one book differ by narrator; a full cast must not blow up
|
||||
the folder name."""
|
||||
"""A full cast must not blow up the folder name."""
|
||||
fry = SimpleNamespace(title="Philosopher's Stone", authors="J.K. Rowling",
|
||||
series="Harry Potter", volume=1, year=None,
|
||||
narrator="Stephen Fry")
|
||||
|
||||
@@ -78,8 +78,7 @@ def test_dialogs_start_closed(page):
|
||||
|
||||
|
||||
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."""
|
||||
"""A CSS display rule can override the hidden attribute."""
|
||||
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
|
||||
|
||||
@@ -82,16 +82,14 @@ def do_import(data: ImportIn, session: Session = Depends(get_session)):
|
||||
results.append({"path": shown, "ok": True, "dest": dest, "appended": True})
|
||||
continue
|
||||
elif req.status != "missing":
|
||||
# usually several entries pointing at the same request: the first one
|
||||
# imported and flipped it, the rest land here
|
||||
# usually several entries pointing at the same request
|
||||
results.append({"path": shown, "ok": False, "error": (
|
||||
f"Anfrage „{req.title}“ (#{req.id}) ist bereits importiert"
|
||||
f"{' nach ' + req.imported_path if req.imported_path else ''}"
|
||||
" — mehrere Einträge auf dieselbe Anfrage? Dann vorher zusammenfassen."
|
||||
)})
|
||||
continue
|
||||
# every source path must stay inside the download dir - merged items
|
||||
# carry files from several folders, so check them all
|
||||
# merged items carry files from several folders, so check them all
|
||||
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):
|
||||
results.append({"path": shown, "ok": False, "error": "path outside download dir"})
|
||||
|
||||
+1
-2
@@ -19,8 +19,7 @@ class Library(Base):
|
||||
root_path: Mapped[str] = mapped_column(String)
|
||||
folder_template: Mapped[str] = mapped_column(String)
|
||||
file_template: Mapped[str] = mapped_column(String)
|
||||
# audible language of this library's editions ("english", "german", …).
|
||||
# empty means no restriction.
|
||||
# "english", "german", … - empty means no restriction
|
||||
language: Mapped[str] = mapped_column(String, default="")
|
||||
|
||||
requests: Mapped[list["BookRequest"]] = relationship(back_populates="library")
|
||||
|
||||
+3
-5
@@ -1,10 +1,8 @@
|
||||
"""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
|
||||
decodes such names with surrogateescape ("\\udcfc"), and those characters cannot
|
||||
be encoded as UTF-8 - a single one of them used to make the whole scan fail with
|
||||
a 500. Display names are cleaned up, while paths keep a lossless representation
|
||||
so the import still finds the file.
|
||||
Linux allows any byte in a name, and Python decodes those with surrogateescape,
|
||||
which JSON cannot encode. Display names are cleaned up, paths keep a lossless
|
||||
representation so the import still finds the file.
|
||||
"""
|
||||
|
||||
import base64
|
||||
|
||||
@@ -8,8 +8,7 @@ LEET = {"0": "o", "1": "i", "3": "e", "4": "a", "5": "s", "7": "t"}
|
||||
|
||||
|
||||
def _deleet(s: str) -> str:
|
||||
"""Undo leetspeak for digits enclosed by letters ("dr3i" -> "drei"). Digits at
|
||||
a word edge stay ("folge 03", "folge03") - those are volume/episode numbers."""
|
||||
""""dr3i" -> "drei". Digits at a word edge stay, those are episode numbers."""
|
||||
return re.sub(r"(?<=[a-z])([013457])(?=[a-z])",
|
||||
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)
|
||||
title_only = fuzz.token_set_ratio(name, normalize(request.title))
|
||||
result = max(s, title_only * 0.95)
|
||||
# for numbered volumes/episodes the number must match, otherwise every
|
||||
# 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
|
||||
# the narrator disambiguates otherwise identical titles
|
||||
narrator = getattr(request, "narrator", "") or ""
|
||||
if narrator and fuzz.partial_ratio(normalize(narrator), name) > 80:
|
||||
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)
|
||||
elif nums:
|
||||
result = min(result, 40.0)
|
||||
# series in the folder path + matching episode number is a strong signal
|
||||
# even when the file name carries only the episode title ("001 - Titel")
|
||||
# series in the path plus a matching number beats a weak title match
|
||||
series = getattr(request, "series", "") or ""
|
||||
if series and request.volume in nums:
|
||||
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]:
|
||||
"""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 = []
|
||||
for item in items:
|
||||
candidates = [r for r in requests if r.media_type == item["media_type"]]
|
||||
|
||||
@@ -7,10 +7,8 @@ from . import tagger
|
||||
|
||||
|
||||
def _cleanup_sources(src_dir: Path, paths: list[Path]) -> None:
|
||||
"""Remove emptied source folders: the item itself plus every folder the files
|
||||
came from, deepest first - "…/100 - Toteninsel/A - Sphinx/CD" contributes all
|
||||
three. Anything still holding files (cover art, booklets) is left alone, and
|
||||
the download dir itself is never removed."""
|
||||
"""Remove emptied source folders, deepest first. Folders that still hold
|
||||
files and the download dir itself are left alone."""
|
||||
download_root = Path(config.DOWNLOAD_DIR).resolve()
|
||||
candidates = {src_dir}
|
||||
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()}")
|
||||
for i, src in enumerate(paths, 1)
|
||||
]
|
||||
# check every target first: moving file by file would leave a half
|
||||
# imported folder behind, and without the check a second title rendering
|
||||
# to the same name would silently overwrite the first one
|
||||
# check every target first: otherwise a second title with the same
|
||||
# rendered name would silently overwrite the first one
|
||||
taken = [d for d in targets if d.exists()]
|
||||
if taken:
|
||||
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:
|
||||
"""Add more files to an audiobook that was already imported - a part that
|
||||
arrived late, or one that failed on the first run. Numbering continues after
|
||||
the files already there, and all tracks are re-tagged with the new total."""
|
||||
"""Add files to an already imported audiobook. Numbering continues after
|
||||
what is there, and all tracks are re-tagged with the new total."""
|
||||
dest_dir = Path(request.imported_path or "")
|
||||
if not dest_dir.is_dir():
|
||||
raise FileNotFoundError(
|
||||
@@ -103,9 +99,8 @@ def append_to_import(files: list[str], request, library) -> str:
|
||||
|
||||
|
||||
def relocate(request, library) -> str:
|
||||
"""Move an already imported audiobook to where the library's templates say it
|
||||
belongs now - after correcting series, title or author. Files are renamed as
|
||||
on import, extras (cover art) move along, and emptied folders are removed."""
|
||||
"""Move an imported audiobook to where the templates say it belongs now.
|
||||
Extras move along, emptied folders are removed."""
|
||||
src = Path(request.imported_path or "")
|
||||
if not src.exists():
|
||||
raise FileNotFoundError(f"Pfad existiert nicht (mehr): {request.imported_path}")
|
||||
|
||||
+16
-29
@@ -4,20 +4,17 @@ from pathlib import Path
|
||||
from .. import config
|
||||
from ..fsnames import encode_path, readable
|
||||
|
||||
# "CD", "CD1", "CD 2", "Disc_03", "Teil 1", "Teil B" as folder name. The index is
|
||||
# optional (single-disc rips just use "CD") and may be a letter. A bare number is
|
||||
# deliberately not a disc: those folders are usually episodes of a series.
|
||||
# "CD", "CD1", "Disc_03", "Teil B" - a bare number is not a disc, those folders
|
||||
# are usually episodes of a series.
|
||||
DISC_DIR_RE = re.compile(
|
||||
r"^(?:cd|disc|disk|dvd|teil|part|vol|volume)[\s._-]*(\d{1,3}|[a-h])?$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# "A - Das Raetsel der Sphinx", "B - Das vergessene Volk": parts of one story,
|
||||
# told apart only by a leading letter. Only used when *every* audio subfolder
|
||||
# follows the pattern.
|
||||
# "A - Titel", "B - Titel": parts of one story, used only when every audio
|
||||
# subfolder follows the pattern.
|
||||
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 ".
|
||||
# Only digits or a single letter count, so a title starting with a short word
|
||||
# ("Der Hobbit …") keeps its first word.
|
||||
# leading track index: digits or a single letter, so "Der Hobbit …" keeps its
|
||||
# first word
|
||||
LEADING_INDEX_RE = re.compile(
|
||||
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:
|
||||
"""Disc index of a disc folder name, 0 for an unnumbered "CD", 1-8 for a
|
||||
letter ("Teil B"). None if the name is not a disc folder at all."""
|
||||
"""Disc index, 0 for an unnumbered "CD". None if it is not a disc folder."""
|
||||
m = DISC_DIR_RE.match(name.strip())
|
||||
if not m:
|
||||
return None
|
||||
@@ -61,10 +57,8 @@ def letter_part(name: str) -> int | None:
|
||||
|
||||
|
||||
def separate_titles(files: list[Path]) -> bool:
|
||||
"""True when a folder's audio files look like distinct books rather than the
|
||||
parts of one audiobook: their names differ beyond a leading index, and each
|
||||
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."""
|
||||
"""True when the files look like separate books: names differ beyond the
|
||||
index and each is big enough. A hint for the UI, the split stays manual."""
|
||||
residuals = {LEADING_INDEX_RE.sub("", f.stem).strip().lower() for f in files}
|
||||
if len(residuals) < 2:
|
||||
return False
|
||||
@@ -75,11 +69,8 @@ def separate_titles(files: list[Path]) -> bool:
|
||||
|
||||
|
||||
def scan(root: Path, split_dirs: bool = False) -> list[dict]:
|
||||
"""Scan the download dir. Returns items: single files, or a directory that is
|
||||
an audiobook unit (contains >1 audio file, or disc subfolders like CD1/CD2,
|
||||
and nothing but audio/junk).
|
||||
With split_dirs=True every file is listed individually (e.g. for folders
|
||||
holding many episodes of a series)."""
|
||||
"""Scan the download dir for single files and audiobook folders.
|
||||
With split_dirs=True every file is listed on its own."""
|
||||
items = []
|
||||
if not root.is_dir():
|
||||
return items
|
||||
@@ -101,15 +92,12 @@ def scan(root: Path, split_dirs: bool = False) -> list[dict]:
|
||||
)
|
||||
|
||||
def disc_audio(d: Path) -> list[Path] | None:
|
||||
"""Audio files of d's disc subfolders ("CD", "CD1", "CD2", …), in disc
|
||||
order. Subfolders without audio (Cover, Scans, …) are ignored. None if d
|
||||
is not a disc-split folder."""
|
||||
"""Audio files of d's disc subfolders in disc order, None if d is not
|
||||
split into discs. Subfolders without audio are ignored."""
|
||||
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]
|
||||
if any(num is None for num, _ in discs):
|
||||
# not disc folders - but "A - …", "B - …" are parts of one story too,
|
||||
# as long as every single one of them follows that shape
|
||||
# "A - …", "B - …" are parts of one story too
|
||||
letters = [(letter_part(s.name), s) for s in audio_subs]
|
||||
if len(letters) < 2 or any(num is None for num, _ in letters):
|
||||
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
|
||||
})
|
||||
return
|
||||
# the download dir itself is a collection, never one audiobook:
|
||||
# a couple of loose files in it would otherwise swallow every
|
||||
# subfolder, because this branch returns early
|
||||
# the download dir is a collection, never one audiobook: it would
|
||||
# swallow every subfolder, because this branch returns early
|
||||
if len(audio) > 1 and d != root:
|
||||
items.append({
|
||||
"path": encode_path(str(d)),
|
||||
|
||||
@@ -7,34 +7,30 @@ from .base import MetadataResult
|
||||
|
||||
# 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(",")]
|
||||
# a marketplace ranks its own language first, so searching for an English
|
||||
# edition works much better on .com than on .de - and vice versa
|
||||
# a marketplace ranks its own language first
|
||||
LANGUAGE_REGIONS = {"english": ["com", "co.uk"], "german": ["de"]}
|
||||
|
||||
RESPONSE_GROUPS = "media,contributors,product_desc,product_attrs,series"
|
||||
PAGE_SIZE = 50
|
||||
# Audible has no "list a series" endpoint (series_asin is rejected), so a series
|
||||
# is collected by paging through keyword searches and keeping the products that
|
||||
# carry its asin. One query never returns the whole catalog, hence the variants.
|
||||
# Audible has no "list a series" endpoint, so a series is collected by paging
|
||||
# through keyword searches; one query never returns the whole catalog.
|
||||
SERIES_QUERY_SUFFIXES = ["", " und der", " und die", " und das", " Folge"]
|
||||
SERIES_MAX_PAGES = 12
|
||||
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
|
||||
|
||||
|
||||
async def _query(client: httpx.AsyncClient, tld: str, query: str, num_results: int = 10,
|
||||
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
|
||||
# any query with fewer than num_results hits.
|
||||
# NB: page is 0-based, page=1 is the second one
|
||||
params = {
|
||||
"keywords": query,
|
||||
"num_results": num_results,
|
||||
"page": page,
|
||||
"response_groups": RESPONSE_GROUPS,
|
||||
}
|
||||
# sorting by relevance helps the top-10 search but measurably shrinks what a
|
||||
# deep paged sweep returns, so the series crawl leaves it off
|
||||
# relevance helps the top-10 search but shrinks a deep paged sweep
|
||||
if sort:
|
||||
params["products_sort_by"] = sort
|
||||
try:
|
||||
@@ -86,7 +82,7 @@ async def search(query: str, language: str = "") -> list[MetadataResult]:
|
||||
seen_asins.add(asin)
|
||||
results.append(_to_result(p))
|
||||
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)]
|
||||
return results
|
||||
|
||||
|
||||
@@ -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
|
||||
the title and the *original* release date instead of a later Audible reissue.
|
||||
The service asks for one request per second and a descriptive user agent; both
|
||||
are honoured here, which makes it deliberately slower than Audible.
|
||||
Often has the episode number in the title and the original release date instead
|
||||
of a later reissue. One request per second and a descriptive user agent are
|
||||
required, which makes it slower than Audible.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -86,8 +85,7 @@ LUCENE_SPECIAL = re.compile(r'[+\-&|!(){}\[\]^"~*?:\\/]+')
|
||||
|
||||
|
||||
def _terms(query: str) -> str:
|
||||
"""Words joined with AND - a quoted phrase would miss "Die drei ??? 100:
|
||||
Toteninsel" when searching for "Die drei ??? Toteninsel"."""
|
||||
"""Words joined with AND; a quoted phrase would miss "… 100: Toteninsel"."""
|
||||
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)
|
||||
|
||||
@@ -102,8 +100,7 @@ async def search(query: str, language: str = "") -> list[MetadataResult]:
|
||||
async with httpx.AsyncClient(timeout=25, follow_redirects=True) as client:
|
||||
data = await _get(client, " AND ".join(terms))
|
||||
|
||||
# the same audio drama exists as many pressings; keep the oldest of each,
|
||||
# that is the original release rather than a reissue
|
||||
# many pressings per drama, keep the oldest: the original release
|
||||
best: dict[tuple, MetadataResult] = {}
|
||||
for release in data.get("releases", []):
|
||||
result = _to_result(release)
|
||||
|
||||
+2
-4
@@ -1,7 +1,6 @@
|
||||
import re
|
||||
|
||||
# only path separators and control chars are truly forbidden on Linux; keep
|
||||
# everything else literal so names like "Die drei ???" survive as-is
|
||||
# keep everything but path separators, so "Die drei ???" survives as-is
|
||||
_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."""
|
||||
author = (request.authors or "").split(",")[0].strip() or "Unknown Author"
|
||||
narrators = getattr(request, "narrator", "") or ""
|
||||
# like {Author}: the first name only, so a full-cast production does not turn
|
||||
# into a 200 character folder name
|
||||
# first name only, a full cast would blow up the folder name
|
||||
narrator = narrators.split(",")[0].strip()
|
||||
values = {
|
||||
"Author": author,
|
||||
|
||||
Reference in New Issue
Block a user