Author SHA1 Message Date
Steppenstreuner 1487512183 feat(ui): watch a series from its dialog and review new parts 2026-10-25 20:17:28 +01:00
Steppenstreuner 55d7c8bdda feat(series): watch series and suggest new parts once a day 2026-10-25 20:17:13 +01:00
Steppenstreuner 175dface50 feat(ui): add a system page with health checks, version and logs 2026-10-25 20:09:06 +01:00
Steppenstreuner 4441c5a24d feat(system): report health problems and keep recent log entries 2026-10-25 20:08:52 +01:00
Steppenstreuner b85bdcf5aa fix(ui): center dialogs again after the global margin reset 2026-10-25 20:04:36 +01:00
Steppenstreuner d0b12d5d09 feat(ui): scan a library and link its files to open requests 2026-10-25 19:55:22 +01:00
Steppenstreuner 46c3c91a8d feat(import): adopt files that are already in a library 2026-10-25 19:55:05 +01:00
Steppenstreuner 46b88e2fd5 feat(ui): sort request lists and move selected requests to another library 2026-10-25 19:40:28 +01:00
Steppenstreuner 07c988c443 feat(requests): add bulk update, bulk delete and a count endpoint 2026-10-25 19:40:10 +01:00
Steppenstreuner f7f89a3cc7 feat(connect): scan audiobookshelf and call a webhook after changes 2026-10-25 19:31:02 +01:00
Steppenstreuner 40a829c80c feat(ui): show the history in an activity view 2026-10-25 19:22:30 +01:00
Steppenstreuner 0c504790df feat(history): record what happens to a request 2026-10-25 19:21:56 +01:00
Steppenstreuner 53927f7e11 refactor(api): use pydantic v2 model_config 2026-10-25 19:17:15 +01:00
Steppenstreuner adc708ae29 feat(ui): keep the current view in the url hash 2026-10-25 19:16:48 +01:00
Steppenstreuner 5f4b3cb564 fix(mover): rename the folder on relocate so audiobookshelf keeps the item and its cover
Build image / image (push) Successful in 52s
2026-09-25 16:40:20 +02:00
Steppenstreuner b51d5754e0 fix(metadata): keep hardcover books whose editions carry no language 2026-09-25 16:11:35 +02:00
Steppenstreuner 91ad7ff98b fix(metadata): find audiobook series through hardcover when audible leaves them untagged
Build image / image (push) Successful in 51s
2026-09-25 16:02:43 +02:00
Steppenstreuner c8d4d239ec fix(ui/import): read author, series and volume out of the download folder name
Build image / image (push) Successful in 1m38s
2026-09-24 13:19:13 +02:00
Steppenstreuner 13e2a0693e fix(metadata): pick the right book row per hardcover series position
Build image / image (push) Successful in 1m49s
2026-09-15 09:10:43 +02:00
Steppenstreuner dc83071a0a fix(ci): drop the socket mount the runnter already provides
Build image / image (push) Successful in 49s
2026-09-01 18:18:08 +02:00
Steppenstreuner e22eded2bf add(ci): pull and recreate the wordarr container after a successful push
Build image / image (push) Failing after 0s
2026-09-01 18:12:22 +02:00
Steppenstreuner 6493d15c52 add(ci): build and push the image on every push to main
Build image / image (push) Successful in 50s
2026-09-01 17:59:33 +02:00
Steppenstreuner 8e7be708b2 add(ui/ux): show the running version and build stamp in the header 2026-09-01 17:50:50 +02:00
Steppenstreuner d9b4c95ca6 add(ui/ux): german and english ui with a toggle in the navbar 2026-09-01 17:50:26 +02:00
Steppenstreuner 169e84901c add(metadata): request a whole ebook series from hardcover 2026-09-01 17:20:34 +02:00
27 changed files with 3068 additions and 425 deletions

No files matched your search

+61
View File
@@ -0,0 +1,61 @@
name: Build image
on:
push:
branches:
- main
jobs:
image:
runs-on: ubuntu-latest
container:
image: gitea/runner-images:ubuntu-latest
# den Docker-Socket haengt der Runner selbst ein, ein zweiter Mount
# waere ein "Duplicate mount point"
options: -v /home/jazz/stacks:/deploy-target
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Tests
run: |
python3 -m venv /tmp/venv
/tmp/venv/bin/pip install --quiet -e .[dev]
/tmp/venv/bin/python -m pytest -q # the browser tests skip without playwright
- name: Image bauen und pushen
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
REGISTRY_USER: ${{ vars.REGISTRY_USER }}
ACTOR: ${{ gitea.actor }}
run: |
command -v docker >/dev/null || { apt-get update -qq && apt-get install -y -qq docker.io; }
sha=$(git rev-parse --short HEAD)
image=gitea.steppencloud.de/admin/wordarr
token=${REGISTRY_TOKEN:-$GITEA_TOKEN}
echo "$token" | docker login gitea.steppencloud.de -u "${REGISTRY_USER:-$ACTOR}" --password-stdin
docker build --build-arg WORDARR_BUILD="$sha" -t "$image:$sha" -t "$image:latest" .
docker push "$image:$sha"
docker push "$image:latest"
echo "Gepusht: $image:latest ($sha)"
- name: wordarr im arr-Stack neu ziehen
run: |
mountpoint -q /deploy-target || {
echo "::error::/deploy-target ist nicht gemountet - valid_volumes im act_runner pruefen"
exit 1
}
if docker compose version >/dev/null 2>&1; then
compose="docker compose"
elif command -v docker-compose >/dev/null 2>&1; then
compose="docker-compose"
else
apt-get update -qq && apt-get install -y -qq docker-compose-v2
compose="docker compose"
fi
cd /deploy-target/arr
$compose pull wordarr # ohne pull bleibt :latest das alte Image
$compose up -d wordarr
echo "Laeuft:"
docker inspect -f '{{.Config.Image}} seit {{.State.StartedAt}}' wordarr
+3 -1
View File
@@ -6,8 +6,10 @@ COPY wordarr ./wordarr
COPY static ./static
RUN pip install --no-cache-dir .
ARG WORDARR_BUILD=""
ENV WORDARR_CONFIG_DIR=/config \
WORDARR_DOWNLOAD_DIR=/mnt/downloads
WORDARR_DOWNLOAD_DIR=/mnt/downloads \
WORDARR_BUILD=$WORDARR_BUILD
VOLUME /config
EXPOSE 8787
+25 -1
View File
@@ -25,6 +25,29 @@ the same mount, otherwise moving turns into copy + delete.
| `WORDARR_EXTRA_EBOOK_EXTENSIONS` | – | extra ebook formats the scan should offer, e.g. `.kepub,.lit,.rtf` |
| `WORDARR_HARDCOVER_TOKEN` | – | Hardcover API token, the best ebook source; without it Hardcover is skipped |
| `WORDARR_GOOGLE_BOOKS_KEY` | – | optional, only needed when the keyless Google Books quota runs dry |
| `WORDARR_ABS_URL` | – | Audiobookshelf base URL, e.g. `http://audiobookshelf`; after an import, relocate or retag it scans the libraries whose folders hold the files, so both containers must mount them under the same path |
| `WORDARR_ABS_TOKEN` | – | API token of an Audiobookshelf admin (only admins may start a scan) |
| `WORDARR_WEBHOOK_URL` | – | gets every history row as JSON (`event`, `request_id`, `title`, `media_type`, `source`, `dest`, `message`) |
| `WORDARR_BUILD` | – | shown next to the version, e.g. the git sha; also a build arg of the image |
The header shows the running version and, behind it, the build stamp (the newest
source file, or `WORDARR_BUILD` when the image was built with it) - the quickest
way to see whether a redeploy actually arrived. `GET /api/version` returns the
same three values. Build with the git sha via
`WORDARR_BUILD=$(git rev-parse --short HEAD) docker compose up -d --build`.
The UI speaks German and English; the button next to the navigation switches
between them and remembers the choice. Without one, the browser language decides.
## Build
`.gitea/workflows/build.yml` runs the tests on every push to `main` and then
builds and pushes `gitea.steppencloud.de/admin/wordarr` as `:latest` and
`:<short sha>`, passing that sha as `WORDARR_BUILD`. It logs in with
`REGISTRY_TOKEN` if the secret exists, otherwise with the runner's own
`GITEA_TOKEN`. The last step pulls the fresh image on the host and recreates
just the wordarr container of the arr stack, so the version in the header is the
commit that was pushed.
## Usage
@@ -34,7 +57,8 @@ the same mount, otherwise moving turns into copy + delete.
2. **Search** – look up a title (ebooks: Hardcover, Google Books and Open
Library, audiobooks: Audible plus MusicBrainz, manga: AniList) and request
it, or add it by hand. The language filter narrows ebook and audiobook hits. For a series,
*Ganze Serie…* requests every episode at once.
*Ganze Serie…* requests every part at once — audiobooks from Audible's keyword
crawl, ebooks from Hardcover's series listing (needs the token).
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. Every row names its format and size, and
+5 -1
View File
@@ -1,6 +1,10 @@
services:
wordarr:
build: .
build:
context: .
args:
# e.g. WORDARR_BUILD=$(git rev-parse --short HEAD) docker compose up -d --build
WORDARR_BUILD: ${WORDARR_BUILD:-}
container_name: wordarr
ports:
- "8787:8787"
+721 -190
View File
File diff suppressed because it is too large. Load diff
+594
View File
@@ -0,0 +1,594 @@
// UI strings for both languages. Keys are grouped by the view they belong to;
// {placeholders} are filled by t().
const STRINGS = {
de: {
"common.cancel": "Abbrechen",
"common.save": "Speichern",
"common.create": "Anlegen",
"common.request": "Anfragen",
"common.search": "Suchen",
"common.all": "Alle",
"common.none": "Keine",
"common.close": "Schließen",
"common.edit": "Bearbeiten",
"common.delete": "Löschen",
"common.link": "Verbinden",
"common.createAndLink": "Anlegen & verbinden",
"common.selectAll": "Alle auswählen",
"common.removeSelected": "Ausgewählte entfernen",
"common.removeSelectedN": "Ausgewählte entfernen ({n})",
"common.library": "Library",
"common.type": "Typ",
"common.title": "Titel",
"common.authors": "Autor(en)",
"common.author": "Autor",
"common.narrator": "Sprecher",
"common.series": "Serie",
"common.volume": "Band",
"common.year": "Jahr",
"common.name": "Name",
"common.path": "Pfad",
"common.language": "Sprache",
"common.score": "Score",
"common.status": "Status",
"common.noLibrary": "— keine Library —",
"common.nothingHere": "Nichts hier.",
"common.loading": "Wird geladen…",
"common.optionalForAll": "optional, für alle",
"common.optional": "optional",
"common.needLibraryFirst": "Erst eine Library für diesen Typ anlegen (Tab Libraries)",
"common.creating": "Lege an…",
"lang.german": "Deutsch",
"lang.english": "Englisch",
"nav.search": "Suche",
"nav.missing": "Missing",
"nav.imported": "Importiert",
"nav.import": "Import",
"nav.settings": "Libraries",
"nav.activity": "Aktivität",
"nav.series": "Serien",
"watch.button": "Beobachten",
"watch.buttonTitle": "Täglich nach neuen Teilen dieser Serie schauen",
"watch.added": "Serie wird beobachtet",
"watch.already": "Diese Serie wird schon beobachtet",
"watch.check": "Jetzt prüfen",
"watch.empty": "Noch keine Serie beobachtet. In der Suche unter „Ganze Serie…“ auf „Beobachten“ klicken.",
"watch.checked": "geprüft {at}",
"watch.error": "Letzte Prüfung fehlgeschlagen: {error}",
"watch.nothingNew": "Nichts Neues.",
"watch.ignore": "Ignorieren",
"watch.remove": "Nicht mehr beobachten",
"watch.confirmRemove": "Diese Serie nicht mehr beobachten?",
"nav.system": "System",
"system.health": "Zustand",
"system.healthy": "Alles in Ordnung.",
"system.version": "Version",
"system.logs": "Letzte Log-Einträge",
"system.level": "Stufe",
"system.source": "Quelle",
"system.message": "Meldung",
"health.download.missing": "Der Download-Ordner {path} existiert nicht.",
"health.download.readonly": "wordarr kann im Download-Ordner {path} nicht schreiben, Importe schlagen fehl.",
"health.library.missing": "Library „{name}“: Der Ordner {path} existiert nicht.",
"health.library.readonly": "Library „{name}“: wordarr kann in {path} nicht schreiben.",
"health.hardcover.token": "Kein Hardcover-Token gesetzt (WORDARR_HARDCOVER_TOKEN), die Ebook-Suche fällt auf Google Books und Open Library zurück.",
"health.abs.incomplete": "Audiobookshelf ist nur halb eingerichtet: WORDARR_ABS_URL und WORDARR_ABS_TOKEN müssen beide gesetzt sein.",
"nav.langToggle": "Switch to English",
"history.time": "Zeit",
"history.event": "Ereignis",
"history.imported": "importiert",
"history.appended": "ergänzt",
"history.relocated": "verschoben",
"history.retagged": "neu getaggt",
"history.deleted": "gelöscht",
"history.failed": "fehlgeschlagen",
"history.adopted": "übernommen",
"adopt.button": "Einlesen",
"adopt.buttonTitle": "Dateien, die schon in der Library liegen, offenen Anfragen zuordnen",
"adopt.heading": "„{name}“ einlesen",
"adopt.intro": "{n} Dateien passen zu offenen Anfragen dieser Library und bleiben, wo sie sind. {rest} weitere ohne Treffer.",
"adopt.submit": "Übernehmen",
"adopt.done": "{n} übernommen",
"adopt.partial": "{n} übernommen, Fehler: {failed}",
"search.languageTitle": "Sprache der Ausgabe — filtert Ebook- und Audiobook-Suche",
"search.allLanguages": "Alle Sprachen",
"search.placeholder": "Titel, Autor oder ISBN…",
"search.manual": "Manuell anlegen",
"search.running": "Suche…",
"search.wholeSeries": "Ganze Serie…",
"search.requested": "✓ Angefragt",
"search.emptyEbook": "Keine Treffer. Tipp: ISBN ohne Bindestriche oder Titel + Autor versuchen — und ggf. „Alle Sprachen“ wählen.",
"search.emptyAudiobook": "Keine Treffer. Tipp: EAN/ISBN funktioniert bei Audible nicht — nach Titel/Autor suchen.",
"search.emptyComic": "Keine Treffer bei AniList (Manga). Westliche Comics per „Manuell anlegen“ erfassen.",
"manual.heading": "Manuell anfragen",
"manual.volumeTo": "bis Band (Bulk, optional)",
"manual.bulkHint": "Legt pro Band/Folge eine Anfrage an, z.B. 1 bis 300",
"manual.externalId": "ISBN/ID",
"manual.noLibraryChosen": "Keine Library gewählt",
"manual.needStartVolume": "Für Bulk bitte Start-Band angeben",
"manual.created": "Anfrage angelegt",
"manual.createdN": "{n} Anfragen angelegt",
"series.fallbackTitle": "Serie",
"series.from": "von Nr.",
"series.to": "bis Nr.",
"series.loading": "{unit} werden gesucht…",
"series.unitVolumes": "Bände",
"series.unitEpisodes": "Folgen",
"series.found": "{n} {unit} gefunden",
"series.range": " (Nr. {from}–{to})",
"series.noteEbook": ". Bände ohne Ausgabe in der gewählten Sprache stehen mit ihrem Originaltitel da.",
"series.noteAudiobook": ". Audible hat keine Serien-Abfrage — einzelne Folgen können fehlen.",
"series.gapVolumes": "… {n} Bände nicht bei Hardcover gefunden ({from}–{to})",
"series.gapVolume": "… {n} Band nicht bei Hardcover gefunden ({from}–{to})",
"series.gapEpisodes": "… {n} Folgen nicht bei Audible gefunden ({from}–{to})",
"series.gapEpisode": "… {n} Folge nicht bei Audible gefunden ({from}–{to})",
"series.requestN": "{n} anfragen",
"series.needLibrary": "Erst eine {type}-Library anlegen (Tab Libraries)",
"series.createdN": "{n} Anfragen angelegt",
"series.skippedN": ", {n} bereits vorhanden",
"list.allTypes": "Alle Typen",
"list.allLibraries": "Alle Libraries",
"list.filterPlaceholder": "Filtern (Titel/Autor/Serie)…",
"list.view": "Ansicht",
"list.viewList": "Liste",
"list.viewSmall": "Kleine Symbole",
"list.viewLarge": "Große Symbole",
"list.confirmDeleteRequests": "{n} Anfrage(n) entfernen?",
"list.confirmDeleteImported": "{n} Eintrag/Einträge entfernen? Die importierten Dateien in der Library bleiben erhalten.",
"list.deletedN": "{n} entfernt",
"list.sort": "Sortierung",
"list.sortNewest": "Neueste zuerst",
"list.sortTitle": "Titel",
"list.sortAuthor": "Autor",
"list.sortSeries": "Serie und Band",
"list.moveTo": "Bibliothek ändern ({n})…",
"list.movedN": "{n} nach {library} verschoben",
"detail.statusMissing": "fehlt noch",
"detail.statusImported": "importiert",
"detail.externalId": "ISBN/ASIN",
"detail.retag": "Speichern & neu taggen",
"detail.relocate": "Speichern & neu ablegen",
"detail.relocateTitle": "Ordner und Dateinamen nach dem Schema der Library neu anlegen",
"detail.relocating": "Lege ab…",
"detail.relocated": "Neu abgelegt: {dest}",
"detail.alreadyPlaced": "Liegt bereits richtig",
"detail.retagging": "Tagge…",
"detail.retagged": "{n} Datei(en) neu getaggt",
"detail.saved": "Anfrage gespeichert",
"detail.duplicateConfirm": "Trotzdem anlegen?",
"import.scan": "Download-Ordner scannen",
"import.splitDirs": "Ordner als Einzeldateien behandeln (z.B. Folgen-Sammlungen)",
"import.scanning": "Scanne…",
"import.candidates": "{n} Kandidat(en) in {dir}",
"import.remaining": "{n} Kandidat(en) übrig — für den aktuellen Stand erneut scannen",
"import.filterPlaceholder": "Filtern (Name/Pfad)…",
"import.modeSuggested": "Nur mit Vorschlag",
"import.modeUnassigned": "Nur ohne Zuordnung",
"import.modeConflict": "Nur Konflikte",
"import.formatTitle": "Nur Einträge mit diesem Dateiformat zeigen",
"import.allFormats": "Alle Formate",
"import.sortScan": "Reihenfolge: Scan",
"import.sortScore": "Score absteigend",
"import.sortName": "Name A–Z",
"import.selectSuggested": "Alle mit Vorschlag auswählen",
"import.selectAllTitle": "Alle Einträge der aktuellen Ansicht auswählen, auch ohne Zuordnung",
"import.selectPage": "Diese Seite auswählen",
"import.selectPageTitle": "Nur die Einträge dieser Seite auswählen, alle anderen abwählen",
"import.deselectAll": "Alle abwählen",
"import.merge": "Ausgewählte zusammenfassen",
"import.mergeTitle": "Mehrteiler: ausgewählte Einträge als ein Hörbuch importieren",
"import.fromNames": "Anfragen aus Ordnernamen",
"import.fromNamesTitle": "Für ausgewählte Einträge ohne Zuordnung Anfragen aus den Ordnernamen anlegen",
"import.colFile": "Datei/Ordner",
"import.colAssignment": "Zuordnung",
"import.prev": "← Zurück",
"import.next": "Weiter →",
"import.run": "Ausgewählte importieren",
"import.skip": "— überspringen —",
"import.appendGroup": "Bereits importiert — Teile anhängen",
"import.quickTitle": "Metadaten suchen und Anfrage direkt verbinden",
"import.unmerge": "Zusammenfassung wieder auflösen",
"import.splitDir": "Ordner in einzelne Titel aufteilen",
"import.parts": "{parts} Teile · {files} Dateien",
"import.maybeSeparate": "{n} Titel?",
"import.folderSeries": "→ {series} #{volume}",
"import.folderSeriesTitle": "Reihe und Band aus dem Ordnernamen ersetzen beim Import die des Eintrags ({series} #{volume})",
"import.maybeSeparateTitle": "Die {n} Dateien tragen verschiedene Titel und sind je groß genug für ein ganzes Buch — mit ✂️ aufteilen",
"import.libraryCellTitle": "Ziel-Library dieser Anfrage ändern",
"import.conflict": "⚠ Konflikt",
"import.conflictTitle": "Mehrere Einträge zeigen auf dieselbe Anfrage",
"import.running": "läuft…",
"import.done": "✓ importiert",
"import.failed": "✗ Fehler",
"import.pageInfo": "Seite {page}/{pages} · {total} Einträge{filtered} · {selected} ausgewählt",
"import.filtered": " (gefiltert: {n})",
"import.movedToLibrary": "„{title}\" → {library}",
"import.selectedN": "{n} Einträge {what} ausgewählt",
"import.selectedWithout": ", davon {n} ohne Zuordnung",
"import.thisPage": "dieser Seite",
"import.inTotal": "insgesamt",
"import.nothingSelected": "Nichts ausgewählt",
"import.needTwo": "Mindestens zwei Einträge auswählen",
"import.sameTypeOnly": "Nur Einträge desselben Typs lassen sich zusammenfassen",
"import.differentFolders": "Die Einträge liegen in verschiedenen Ordnern",
"import.mergedN": "{n} Einträge als „{name}\" zusammengefasst ({files} Dateien)",
"import.splitN": "„{name}\" in {n} Einzeltitel aufgeteilt",
"import.nothingImported": "Nichts importiert: alle ausgewählten Einträge waren mehreren Anfragen zugeordnet — Filter „Nur Konflikte\" zeigt sie.",
"import.summary": "{n} importiert",
"import.summaryFailed": ", {n} Fehler",
"import.summarySkipped": "{n} Anfrage(n) waren mehreren Einträgen zugeordnet und wurden übersprungen — Filter „Nur Konflikte\" zeigt sie.",
"import.finished": "Import fertig",
"import.finishedFailed": "Import fertig, {n} Fehler",
"import.progressDone": "{total}/{total} · fertig",
"names.heading": "Anfragen aus Ordnernamen",
"names.intro": "{n} Einträge ohne Zuordnung ({type}). Titel und Folgennummer kommen aus dem Ordnernamen; Serie und Autor gelten für alle.",
"names.more": "… und {n} weitere",
"names.noneWithout": "Keine ausgewählten Einträge ohne Zuordnung",
"names.result": "{created} Anfragen angelegt{skipped}, {linked} Einträge verbunden",
"names.existed": ", {n} gab es schon",
"conflict.heading": "Mehrfach zugeordnete Anfragen",
"conflict.intro": "{n} Anfrage(n) sind mehreren Einträgen zugeordnet. Gehören die Einträge zusammen (Mehrteiler), lassen sie sich als ein Hörbuch importieren — sonst überspringst du sie, korrigierst die Zuordnung und importierst sie später.",
"conflict.merge": "Als je ein Hörbuch zusammenfassen",
"conflict.skip": "Konflikte überspringen, Rest importieren",
"quick.heading": "Suchen & Anfragen",
"quick.placeholder": "Titel/Serie suchen…",
"quick.targetLibrary": "Ziel-Library",
"quick.openRequests": "Offene Anfragen",
"quick.reqFilter": "nach Titel, Autor, Serie filtern…",
"quick.manualSummary": "Nicht bei Audible? Manuell anlegen",
"quick.editSummary": "Felder anpassen",
"quick.episodeVolume": "Folge/Band",
"quick.searching": "Suche läuft…",
"quick.episodeNote": "Folge {n} — nicht mitgesucht",
"quick.matchingOpen": "Passende offene Requests:",
"quick.hits": "{shown} von {total} Treffern ({open} offen)",
"quick.noHits": "Kein Treffer unter {open} offenen Anfragen",
"quick.linked": "Mit bestehendem Request verbunden",
"quick.editTitle": "Felder vor dem Anlegen anpassen (z.B. Serie)",
"quick.pickTitle": "Anfrage anlegen und mit diesem Eintrag verbinden",
"quick.basedOn": "Übernommen von Audible: „{title}\" — Sprecher, Cover, Jahr und ASIN bleiben erhalten.",
"quick.requestedAndLinked": "„{title}\" angefragt und verbunden",
"quick.needTitle": "Titel angeben",
"lib.heading": "Libraries",
"lib.folderTemplate": "Ordner-Schema",
"lib.fileTemplate": "Datei-Schema",
"lib.new": "Neue Library",
"lib.edit": "Library „{name}“ bearbeiten",
"lib.namePlaceholder": "Name (z.B. Kids)",
"lib.folderPlaceholder": "Ordner-Schema (optional)",
"lib.filePlaceholder": "Datei-Schema (optional)",
"lib.languageTitle": "Sprache der Ausgaben in dieser Library — filtert die Metadaten-Suche",
"lib.anyLanguage": "Sprache: egal",
"lib.placeholders": "Platzhalter: {Author} {Authors} {Narrator} {Narrators} {Title} {Year} {Series} {Volume} — {Author}/{Narrator} nennen den ersten Namen, {Authors}/{Narrators} alle. Leere Werte fallen samt Trenner weg.",
"lib.confirmDelete": "Library löschen?",
"lib.saved": "Library gespeichert — gilt für künftige Importe",
"lib.created": "Library angelegt",
},
en: {
"common.cancel": "Cancel",
"common.save": "Save",
"common.create": "Create",
"common.request": "Request",
"common.search": "Search",
"common.all": "All",
"common.none": "None",
"common.close": "Close",
"common.edit": "Edit",
"common.delete": "Delete",
"common.link": "Link",
"common.createAndLink": "Create & link",
"common.selectAll": "Select all",
"common.removeSelected": "Remove selected",
"common.removeSelectedN": "Remove selected ({n})",
"common.library": "Library",
"common.type": "Type",
"common.title": "Title",
"common.authors": "Author(s)",
"common.author": "Author",
"common.narrator": "Narrator",
"common.series": "Series",
"common.volume": "Volume",
"common.year": "Year",
"common.name": "Name",
"common.path": "Path",
"common.language": "Language",
"common.score": "Score",
"common.status": "Status",
"common.noLibrary": "— no library —",
"common.nothingHere": "Nothing here.",
"common.loading": "Loading…",
"common.optionalForAll": "optional, for all",
"common.optional": "optional",
"common.needLibraryFirst": "Create a library for this type first (Libraries tab)",
"common.creating": "Creating…",
"lang.german": "German",
"lang.english": "English",
"nav.search": "Search",
"nav.missing": "Missing",
"nav.imported": "Imported",
"nav.import": "Import",
"nav.settings": "Libraries",
"nav.activity": "Activity",
"nav.series": "Series",
"watch.button": "Watch",
"watch.buttonTitle": "Look for new parts of this series every day",
"watch.added": "Series is being watched",
"watch.already": "This series is already watched",
"watch.check": "Check now",
"watch.empty": "No series watched yet. In the search, open “Whole series…” and click “Watch”.",
"watch.checked": "checked {at}",
"watch.error": "Last check failed: {error}",
"watch.nothingNew": "Nothing new.",
"watch.ignore": "Ignore",
"watch.remove": "Stop watching",
"watch.confirmRemove": "Stop watching this series?",
"nav.system": "System",
"system.health": "Health",
"system.healthy": "All good.",
"system.version": "Version",
"system.logs": "Recent log entries",
"system.level": "Level",
"system.source": "Source",
"system.message": "Message",
"health.download.missing": "The download folder {path} does not exist.",
"health.download.readonly": "wordarr cannot write to the download folder {path}, imports will fail.",
"health.library.missing": "Library “{name}”: the folder {path} does not exist.",
"health.library.readonly": "Library “{name}”: wordarr cannot write to {path}.",
"health.hardcover.token": "No Hardcover token set (WORDARR_HARDCOVER_TOKEN), ebook search falls back to Google Books and Open Library.",
"health.abs.incomplete": "Audiobookshelf is only half set up: WORDARR_ABS_URL and WORDARR_ABS_TOKEN must both be set.",
"nav.langToggle": "Auf Deutsch umschalten",
"history.time": "Time",
"history.event": "Event",
"history.imported": "imported",
"history.appended": "appended",
"history.relocated": "relocated",
"history.retagged": "retagged",
"history.deleted": "deleted",
"history.failed": "failed",
"history.adopted": "adopted",
"adopt.button": "Scan",
"adopt.buttonTitle": "Link files already in the library to open requests",
"adopt.heading": "Scan “{name}”",
"adopt.intro": "{n} files match open requests of this library and stay where they are. {rest} more without a match.",
"adopt.submit": "Adopt",
"adopt.done": "{n} adopted",
"adopt.partial": "{n} adopted, errors: {failed}",
"search.languageTitle": "Edition language — filters the ebook and audiobook search",
"search.allLanguages": "All languages",
"search.placeholder": "Title, author or ISBN…",
"search.manual": "Add manually",
"search.running": "Searching…",
"search.wholeSeries": "Whole series…",
"search.requested": "✓ Requested",
"search.emptyEbook": "No hits. Try the ISBN without dashes, or title + author — and maybe pick “All languages”.",
"search.emptyAudiobook": "No hits. EAN/ISBN does not work on Audible — search by title/author.",
"search.emptyComic": "No hits on AniList (manga). Add western comics via “Add manually”.",
"manual.heading": "Request manually",
"manual.volumeTo": "to volume (bulk, optional)",
"manual.bulkHint": "Creates one request per volume/episode, e.g. 1 to 300",
"manual.externalId": "ISBN/ID",
"manual.noLibraryChosen": "No library chosen",
"manual.needStartVolume": "Bulk needs a starting volume",
"manual.created": "Request created",
"manual.createdN": "{n} requests created",
"series.fallbackTitle": "Series",
"series.from": "from no.",
"series.to": "to no.",
"series.loading": "Looking for {unit}…",
"series.unitVolumes": "volumes",
"series.unitEpisodes": "episodes",
"series.found": "{n} {unit} found",
"series.range": " (no. {from}–{to})",
"series.noteEbook": ". Volumes without an edition in the chosen language keep their original title.",
"series.noteAudiobook": ". Audible has no series query — single episodes can be missing.",
"series.gapVolumes": "… {n} volumes not found on Hardcover ({from}–{to})",
"series.gapVolume": "… {n} volume not found on Hardcover ({from}–{to})",
"series.gapEpisodes": "… {n} episodes not found on Audible ({from}–{to})",
"series.gapEpisode": "… {n} episode not found on Audible ({from}–{to})",
"series.requestN": "Request {n}",
"series.needLibrary": "Create an {type} library first (Libraries tab)",
"series.createdN": "{n} requests created",
"series.skippedN": ", {n} already there",
"list.allTypes": "All types",
"list.allLibraries": "All libraries",
"list.filterPlaceholder": "Filter (title/author/series)…",
"list.view": "View",
"list.viewList": "List",
"list.viewSmall": "Small icons",
"list.viewLarge": "Large icons",
"list.confirmDeleteRequests": "Remove {n} request(s)?",
"list.confirmDeleteImported": "Remove {n} entr(y/ies)? The imported files stay in the library.",
"list.deletedN": "{n} removed",
"list.sort": "Sort order",
"list.sortNewest": "Newest first",
"list.sortTitle": "Title",
"list.sortAuthor": "Author",
"list.sortSeries": "Series and volume",
"list.moveTo": "Change library ({n})…",
"list.movedN": "{n} moved to {library}",
"detail.statusMissing": "still missing",
"detail.statusImported": "imported",
"detail.externalId": "ISBN/ASIN",
"detail.retag": "Save & re-tag",
"detail.relocate": "Save & re-file",
"detail.relocateTitle": "Rebuild folder and file names from the library's scheme",
"detail.relocating": "Filing…",
"detail.relocated": "Re-filed: {dest}",
"detail.alreadyPlaced": "Already in the right place",
"detail.retagging": "Tagging…",
"detail.retagged": "{n} file(s) re-tagged",
"detail.saved": "Request saved",
"detail.duplicateConfirm": "Create anyway?",
"import.scan": "Scan download folder",
"import.splitDirs": "Treat folders as single files (e.g. episode collections)",
"import.scanning": "Scanning…",
"import.candidates": "{n} candidate(s) in {dir}",
"import.remaining": "{n} candidate(s) left — scan again for the current state",
"import.filterPlaceholder": "Filter (name/path)…",
"import.modeSuggested": "With suggestion only",
"import.modeUnassigned": "Unassigned only",
"import.modeConflict": "Conflicts only",
"import.formatTitle": "Show only entries with this file format",
"import.allFormats": "All formats",
"import.sortScan": "Order: scan",
"import.sortScore": "Score descending",
"import.sortName": "Name A–Z",
"import.selectSuggested": "Select all with a suggestion",
"import.selectAllTitle": "Select every entry of the current view, unassigned ones too",
"import.selectPage": "Select this page",
"import.selectPageTitle": "Select only this page's entries, deselect all others",
"import.deselectAll": "Deselect all",
"import.merge": "Merge selected",
"import.mergeTitle": "Multi-part title: import the selected entries as one audiobook",
"import.fromNames": "Requests from folder names",
"import.fromNamesTitle": "Create requests from the folder names of the selected unassigned entries",
"import.colFile": "File/folder",
"import.colAssignment": "Assignment",
"import.prev": "← Back",
"import.next": "Next →",
"import.run": "Import selected",
"import.skip": "— skip —",
"import.appendGroup": "Already imported — append parts",
"import.quickTitle": "Search metadata and link the request right away",
"import.unmerge": "Undo the merge",
"import.splitDir": "Split the folder into single titles",
"import.parts": "{parts} parts · {files} files",
"import.maybeSeparate": "{n} titles?",
"import.folderSeries": "→ {series} #{volume}",
"import.folderSeriesTitle": "The series and volume from the folder name replace the request's on import ({series} #{volume})",
"import.maybeSeparateTitle": "The {n} files carry different titles and are each big enough for a whole book — split with ✂️",
"import.libraryCellTitle": "Change this request's target library",
"import.conflict": "⚠ Conflict",
"import.conflictTitle": "Several entries point at the same request",
"import.running": "running…",
"import.done": "✓ imported",
"import.failed": "✗ Error",
"import.pageInfo": "Page {page}/{pages} · {total} entries{filtered} · {selected} selected",
"import.filtered": " (filtered: {n})",
"import.movedToLibrary": "“{title}” → {library}",
"import.selectedN": "{n} entries {what} selected",
"import.selectedWithout": ", {n} of them unassigned",
"import.thisPage": "on this page",
"import.inTotal": "in total",
"import.nothingSelected": "Nothing selected",
"import.needTwo": "Select at least two entries",
"import.sameTypeOnly": "Only entries of the same type can be merged",
"import.differentFolders": "The entries live in different folders",
"import.mergedN": "{n} entries merged as “{name}” ({files} files)",
"import.splitN": "“{name}” split into {n} single titles",
"import.nothingImported": "Nothing imported: every selected entry was assigned to several requests — the “Conflicts only” filter shows them.",
"import.summary": "{n} imported",
"import.summaryFailed": ", {n} failed",
"import.summarySkipped": "{n} request(s) were assigned to several entries and were skipped — the “Conflicts only” filter shows them.",
"import.finished": "Import finished",
"import.finishedFailed": "Import finished, {n} failed",
"import.progressDone": "{total}/{total} · done",
"names.heading": "Requests from folder names",
"names.intro": "{n} unassigned entries ({type}). Title and episode number come from the folder name; series and author apply to all of them.",
"names.more": "… and {n} more",
"names.noneWithout": "No selected entries without an assignment",
"names.result": "{created} requests created{skipped}, {linked} entries linked",
"names.existed": ", {n} already existed",
"conflict.heading": "Requests assigned more than once",
"conflict.intro": "{n} request(s) are assigned to several entries. If those entries belong together (a multi-part title), they can be imported as one audiobook — otherwise skip them, fix the assignment and import them later.",
"conflict.merge": "Merge each into one audiobook",
"conflict.skip": "Skip conflicts, import the rest",
"quick.heading": "Search & request",
"quick.placeholder": "Search title/series…",
"quick.targetLibrary": "Target library",
"quick.openRequests": "Open requests",
"quick.reqFilter": "filter by title, author, series…",
"quick.manualSummary": "Not on Audible? Add manually",
"quick.editSummary": "Adjust the fields",
"quick.episodeVolume": "Episode/volume",
"quick.searching": "Searching…",
"quick.episodeNote": "Episode {n} — not part of the search",
"quick.matchingOpen": "Matching open requests:",
"quick.hits": "{shown} of {total} hits ({open} open)",
"quick.noHits": "No hit among {open} open requests",
"quick.linked": "Linked to the existing request",
"quick.editTitle": "Adjust the fields before creating (e.g. the series)",
"quick.pickTitle": "Create the request and link it to this entry",
"quick.basedOn": "Taken from Audible: “{title}” — narrator, cover, year and ASIN are kept.",
"quick.requestedAndLinked": "“{title}” requested and linked",
"quick.needTitle": "Enter a title",
"lib.heading": "Libraries",
"lib.folderTemplate": "Folder scheme",
"lib.fileTemplate": "File scheme",
"lib.new": "New library",
"lib.edit": "Edit library “{name}”",
"lib.namePlaceholder": "Name (e.g. Kids)",
"lib.folderPlaceholder": "Folder scheme (optional)",
"lib.filePlaceholder": "File scheme (optional)",
"lib.languageTitle": "Language of this library's editions — filters the metadata search",
"lib.anyLanguage": "Language: any",
"lib.placeholders": "Placeholders: {Author} {Authors} {Narrator} {Narrators} {Title} {Year} {Series} {Volume} — {Author}/{Narrator} name the first one, {Authors}/{Narrators} all of them. Empty values drop out with their separator.",
"lib.confirmDelete": "Delete library?",
"lib.saved": "Library saved — applies to future imports",
"lib.created": "Library created",
},
};
const LANG_KEY = "wordarr.lang";
function initialLang() {
try {
const saved = localStorage.getItem(LANG_KEY);
if (saved && STRINGS[saved]) return saved;
} catch {}
return (navigator.language || "en").startsWith("de") ? "de" : "en";
}
let lang = initialLang();
// {Placeholders} of the naming scheme must survive, so only known vars are replaced
function t(key, vars) {
let s = STRINGS[lang][key] ?? STRINGS.en[key] ?? key;
if (vars) {
for (const [name, value] of Object.entries(vars)) {
s = s.split("{" + name + "}").join(String(value));
}
}
return s;
}
const I18N_ATTRS = { "i18nPlaceholder": "placeholder", "i18nTitle": "title", "i18nAria": "aria-label" };
function applyI18n(root = document) {
root.querySelectorAll("[data-i18n]").forEach((el) => (el.textContent = t(el.dataset.i18n)));
for (const [data, attr] of Object.entries(I18N_ATTRS)) {
root.querySelectorAll(`[data-${data.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase())}]`)
.forEach((el) => el.setAttribute(attr, t(el.dataset[data])));
}
document.documentElement.lang = lang;
}
function setLang(next) {
lang = next;
try { localStorage.setItem(LANG_KEY, next); } catch {}
applyI18n();
if (typeof onLangChange === "function") onLangChange();
}
+218 -176
View File
@@ -11,15 +11,26 @@
</head>
<body>
<header>
<h1>📚 wordarr</h1>
<h1>📚 wordarr <span id="app-version"></span></h1>
<nav>
<button data-view="search" class="active">Suche</button>
<button data-view="search" class="active" data-i18n="nav.search"></button>
<button data-view="missing">
Missing <span id="missing-badge" class="badge" hidden></span>
<span data-i18n="nav.missing"></span>
<span id="missing-badge" class="badge" hidden></span>
</button>
<button data-view="imported">Importiert</button>
<button data-view="import">Import</button>
<button data-view="settings">Libraries</button>
<button data-view="imported" data-i18n="nav.imported"></button>
<button data-view="import" data-i18n="nav.import"></button>
<button data-view="series">
<span data-i18n="nav.series"></span>
<span id="watch-badge" class="badge" hidden></span>
</button>
<button data-view="activity" data-i18n="nav.activity"></button>
<button data-view="settings" data-i18n="nav.settings"></button>
<button data-view="system">
<span data-i18n="nav.system"></span>
<span id="health-badge" class="badge" hidden></span>
</button>
<button id="lang-toggle" class="secondary" data-i18n-title="nav.langToggle"></button>
</nav>
</header>
<main>
@@ -30,71 +41,68 @@
<option value="audiobook">Audiobook</option>
<option value="comic">Comic/Manga</option>
</select>
<select id="search-language" title="Sprache der Ausgabe — filtert Ebook- und Audiobook-Suche">
<option value="">Alle Sprachen</option>
<option value="german">Deutsch</option>
<option value="english">Englisch</option>
<select id="search-language" data-i18n-title="search.languageTitle">
<option value="" data-i18n="search.allLanguages"></option>
<option value="german" data-i18n="lang.german"></option>
<option value="english" data-i18n="lang.english"></option>
</select>
<input id="search-q" placeholder="Titel, Autor oder ISBN…" required />
<button type="submit" id="search-btn">Suchen</button>
<button type="button" id="manual-btn" class="secondary">
Manuell anlegen
</button>
<input id="search-q" data-i18n-placeholder="search.placeholder" required />
<button type="submit" id="search-btn" data-i18n="common.search"></button>
<button type="button" id="manual-btn" class="secondary" data-i18n="search.manual"></button>
</form>
<div id="search-results" class="cards"></div>
<dialog id="manual-dialog">
<form id="manual-form" method="dialog">
<h3>Manuell anfragen</h3>
<h3 data-i18n="manual.heading"></h3>
<label
>Typ
><span data-i18n="common.type"></span>
<select name="media_type">
<option value="ebook">Ebook</option>
<option value="audiobook">Audiobook</option>
<option value="comic">Comic/Manga</option>
</select>
</label>
<label>Titel <input name="title" required /></label>
<label>Autor(en) <input name="authors" /></label>
<label>Sprecher <input name="narrator" /></label>
<label>Serie <input name="series" /></label>
<label>Band <input name="volume" type="number" min="0" /></label>
<label><span data-i18n="common.title"></span> <input name="title" required /></label>
<label><span data-i18n="common.authors"></span> <input name="authors" /></label>
<label><span data-i18n="common.narrator"></span> <input name="narrator" /></label>
<label><span data-i18n="common.series"></span> <input name="series" /></label>
<label><span data-i18n="common.volume"></span> <input name="volume" type="number" min="0" /></label>
<label
>bis Band (Bulk, optional)
><span data-i18n="manual.volumeTo"></span>
<input name="volume_to" type="number" min="0" />
<span class="muted"
>Legt pro Band/Folge eine Anfrage an, z.B. 1 bis 300</span
>
<span class="muted" data-i18n="manual.bulkHint"></span>
</label>
<label>Jahr <input name="year" type="number" /></label>
<label>ISBN/ID <input name="external_id" /></label>
<label><span data-i18n="common.year"></span> <input name="year" type="number" /></label>
<label><span data-i18n="manual.externalId"></span> <input name="external_id" /></label>
<label
>Library
><span data-i18n="common.library"></span>
<select name="library_id" id="manual-library"></select
></label>
<div class="row">
<button value="cancel" class="secondary">Abbrechen</button>
<button value="ok" id="manual-submit">Anfragen</button>
<button value="cancel" class="secondary" data-i18n="common.cancel"></button>
<button value="ok" id="manual-submit" data-i18n="common.request"></button>
</div>
</form>
</dialog>
<dialog id="series-dialog">
<h3 id="series-title">Serie anfragen</h3>
<h3 id="series-title"></h3>
<p class="muted" id="series-info"></p>
<div class="bar wrap">
<label>von Folge <input id="series-from" type="number" min="0" /></label>
<label>bis Folge <input id="series-to" type="number" min="0" /></label>
<button type="button" id="series-all" class="secondary">Alle</button>
<button type="button" id="series-none" class="secondary">Keine</button>
<label><span data-i18n="series.from"></span> <input id="series-from" type="number" min="0" /></label>
<label><span data-i18n="series.to"></span> <input id="series-to" type="number" min="0" /></label>
<button type="button" id="series-all" class="secondary" data-i18n="common.all"></button>
<button type="button" id="series-none" class="secondary" data-i18n="common.none"></button>
</div>
<div id="series-list" class="series-list"></div>
<div class="row">
<label>Library <select id="series-library"></select></label>
<label><span data-i18n="common.library"></span> <select id="series-library"></select></label>
</div>
<div class="row">
<button type="button" id="series-cancel" class="secondary">Abbrechen</button>
<button type="button" id="series-submit">Anfragen</button>
<button type="button" id="series-cancel" class="secondary" data-i18n="common.cancel"></button>
<button type="button" id="series-watch" class="secondary" data-i18n="watch.button" data-i18n-title="watch.buttonTitle"></button>
<button type="button" id="series-submit" data-i18n="common.request"></button>
</div>
</dialog>
</section>
@@ -102,23 +110,28 @@
<section id="view-missing" hidden>
<div class="bar wrap">
<select id="missing-filter-type">
<option value="">Alle Typen</option>
<option value="" data-i18n="list.allTypes"></option>
<option value="ebook">Ebook</option>
<option value="audiobook">Audiobook</option>
<option value="comic">Comic/Manga</option>
</select>
<select id="missing-filter-library">
<option value="">Alle Libraries</option>
<option value="" data-i18n="list.allLibraries"></option>
</select>
<input id="missing-search" placeholder="Filtern (Titel/Autor/Serie)…" />
<button id="missing-select-all" class="secondary">Alle auswählen</button>
<button id="missing-bulk-delete" class="danger" hidden>
Ausgewählte entfernen
</button>
<div class="view-toggle" role="group" aria-label="Ansicht">
<button data-viewmode="list" title="Liste">☰</button>
<button data-viewmode="small" title="Kleine Symbole">▦</button>
<button data-viewmode="large" title="Große Symbole">⊞</button>
<input id="missing-search" data-i18n-placeholder="list.filterPlaceholder" />
<select id="missing-sort" data-i18n-title="list.sort">
<option value="newest" data-i18n="list.sortNewest"></option>
<option value="title" data-i18n="list.sortTitle"></option>
<option value="author" data-i18n="list.sortAuthor"></option>
<option value="series" data-i18n="list.sortSeries"></option>
</select>
<button id="missing-select-all" class="secondary" data-i18n="common.selectAll"></button>
<select id="missing-move-library" hidden></select>
<button id="missing-bulk-delete" class="danger" hidden data-i18n="common.removeSelected"></button>
<div class="view-toggle" role="group" data-i18n-aria="list.view">
<button data-viewmode="list" data-i18n-title="list.viewList">☰</button>
<button data-viewmode="small" data-i18n-title="list.viewSmall">▦</button>
<button data-viewmode="large" data-i18n-title="list.viewLarge">⊞</button>
</div>
</div>
<div id="missing-list" class="cards"></div>
@@ -126,15 +139,19 @@
<section id="view-imported" hidden>
<div class="bar wrap">
<input id="imported-search" placeholder="Filtern (Titel/Autor/Serie)…" />
<button id="imported-select-all" class="secondary">Alle auswählen</button>
<button id="imported-bulk-delete" class="danger" hidden>
Ausgewählte entfernen
</button>
<div class="view-toggle" role="group" aria-label="Ansicht">
<button data-viewmode="list" title="Liste">☰</button>
<button data-viewmode="small" title="Kleine Symbole">▦</button>
<button data-viewmode="large" title="Große Symbole">⊞</button>
<input id="imported-search" data-i18n-placeholder="list.filterPlaceholder" />
<select id="imported-sort" data-i18n-title="list.sort">
<option value="newest" data-i18n="list.sortNewest"></option>
<option value="title" data-i18n="list.sortTitle"></option>
<option value="author" data-i18n="list.sortAuthor"></option>
<option value="series" data-i18n="list.sortSeries"></option>
</select>
<button id="imported-select-all" class="secondary" data-i18n="common.selectAll"></button>
<button id="imported-bulk-delete" class="danger" hidden data-i18n="common.removeSelected"></button>
<div class="view-toggle" role="group" data-i18n-aria="list.view">
<button data-viewmode="list" data-i18n-title="list.viewList">☰</button>
<button data-viewmode="small" data-i18n-title="list.viewSmall">▦</button>
<button data-viewmode="large" data-i18n-title="list.viewLarge">⊞</button>
</div>
</div>
<div id="imported-list" class="cards"></div>
@@ -142,73 +159,61 @@
<section id="view-import" hidden>
<div class="bar wrap">
<button id="scan-btn">Download-Ordner scannen</button>
<button id="scan-btn" data-i18n="import.scan"></button>
<label class="muted"
><input type="checkbox" id="split-dirs" /> Ordner als Einzeldateien
behandeln (z.B. Folgen-Sammlungen)</label
>
><input type="checkbox" id="split-dirs" /> <span data-i18n="import.splitDirs"></span
></label>
<span id="scan-info" class="muted"></span>
</div>
<div class="bar wrap" id="import-toolbar" hidden>
<input id="import-filter-text" placeholder="Filtern (Name/Pfad)…" />
<input id="import-filter-text" data-i18n-placeholder="import.filterPlaceholder" />
<select id="import-filter-mode">
<option value="all">Alle</option>
<option value="suggested">Nur mit Vorschlag</option>
<option value="unassigned">Nur ohne Zuordnung</option>
<option value="conflict">Nur Konflikte</option>
<option value="all" data-i18n="common.all"></option>
<option value="suggested" data-i18n="import.modeSuggested"></option>
<option value="unassigned" data-i18n="import.modeUnassigned"></option>
<option value="conflict" data-i18n="import.modeConflict"></option>
</select>
<select id="import-filter-format" title="Nur Einträge mit diesem Dateiformat zeigen">
<option value="">Alle Formate</option>
<select id="import-filter-format" data-i18n-title="import.formatTitle">
<option value="" data-i18n="import.allFormats"></option>
</select>
<select id="import-sort">
<option value="none">Reihenfolge: Scan</option>
<option value="score">Score absteigend</option>
<option value="name">Name A–Z</option>
<option value="none" data-i18n="import.sortScan"></option>
<option value="score" data-i18n="import.sortScore"></option>
<option value="name" data-i18n="import.sortName"></option>
</select>
<button id="import-select-suggested" class="secondary">
Alle mit Vorschlag auswählen
</button>
<button id="import-select-suggested" class="secondary" data-i18n="import.selectSuggested"></button>
<button id="import-select-all" class="secondary"
title="Alle Einträge der aktuellen Ansicht auswählen, auch ohne Zuordnung">
Alle auswählen
</button>
data-i18n-title="import.selectAllTitle" data-i18n="common.selectAll"></button>
<button id="import-select-page" class="secondary"
title="Nur die Einträge dieser Seite auswählen, alle anderen abwählen">
Diese Seite auswählen
</button>
<button id="import-deselect-all" class="secondary">
Alle abwählen
</button>
<button id="import-merge" class="secondary" title="Mehrteiler: ausgewählte Einträge als ein Hörbuch importieren">
Ausgewählte zusammenfassen
</button>
data-i18n-title="import.selectPageTitle" data-i18n="import.selectPage"></button>
<button id="import-deselect-all" class="secondary" data-i18n="import.deselectAll"></button>
<button id="import-merge" class="secondary"
data-i18n-title="import.mergeTitle" data-i18n="import.merge"></button>
<button id="import-from-names" class="secondary"
title="Für ausgewählte Einträge ohne Zuordnung Anfragen aus den Ordnernamen anlegen">
Anfragen aus Ordnernamen
</button>
data-i18n-title="import.fromNamesTitle" data-i18n="import.fromNames"></button>
</div>
<div class="table-scroll">
<table id="import-table" hidden>
<thead>
<tr>
<th></th>
<th>Datei/Ordner</th>
<th>Typ</th>
<th>Zuordnung</th>
<th>Library</th>
<th>Score</th>
<th data-i18n="import.colFile"></th>
<th data-i18n="common.type"></th>
<th data-i18n="import.colAssignment"></th>
<th data-i18n="common.library"></th>
<th id="import-score-head" data-i18n="common.score"></th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<div class="bar" id="import-pager" hidden>
<button id="import-prev" class="secondary">← Zurück</button>
<button id="import-prev" class="secondary" data-i18n="import.prev"></button>
<span id="import-page-info" class="muted"></span>
<button id="import-next" class="secondary">Weiter →</button>
<button id="import-next" class="secondary" data-i18n="import.next"></button>
</div>
<div class="bar">
<button id="import-btn" hidden>Ausgewählte importieren</button>
<button id="import-btn" hidden data-i18n="import.run"></button>
<div id="import-progress" hidden>
<progress max="1" value="0"></progress>
<span class="muted"></span>
@@ -218,49 +223,47 @@
<div id="import-results"></div>
<dialog id="names-dialog">
<h3>Anfragen aus Ordnernamen</h3>
<h3 data-i18n="names.heading"></h3>
<p class="muted" id="names-intro"></p>
<div class="bar wrap">
<label>Serie <input id="names-series" placeholder="optional, für alle" /></label>
<label>Autor <input id="names-authors" placeholder="optional, für alle" /></label>
<label>Library <select id="names-library"></select></label>
<label><span data-i18n="common.series"></span>
<input id="names-series" data-i18n-placeholder="common.optionalForAll" /></label>
<label><span data-i18n="common.author"></span>
<input id="names-authors" data-i18n-placeholder="common.optionalForAll" /></label>
<label><span data-i18n="common.library"></span> <select id="names-library"></select></label>
</div>
<div id="names-preview" class="series-list"></div>
<div class="row">
<button type="button" id="names-cancel" class="secondary">Abbrechen</button>
<button type="button" id="names-submit">Anlegen &amp; verbinden</button>
<button type="button" id="names-cancel" class="secondary" data-i18n="common.cancel"></button>
<button type="button" id="names-submit" data-i18n="common.createAndLink"></button>
</div>
</dialog>
<dialog id="conflict-dialog">
<h3>Mehrfach zugeordnete Anfragen</h3>
<h3 data-i18n="conflict.heading"></h3>
<p class="muted" id="conflict-intro"></p>
<div id="conflict-list" class="series-list"></div>
<div class="row">
<button type="button" id="conflict-cancel" class="secondary">Abbrechen</button>
<button type="button" id="conflict-merge" class="secondary">
Als je ein Hörbuch zusammenfassen
</button>
<button type="button" id="conflict-skip">
Konflikte überspringen, Rest importieren
</button>
<button type="button" id="conflict-cancel" class="secondary" data-i18n="common.cancel"></button>
<button type="button" id="conflict-merge" class="secondary" data-i18n="conflict.merge"></button>
<button type="button" id="conflict-skip" data-i18n="conflict.skip"></button>
</div>
</dialog>
<dialog id="quick-dialog">
<h3>Suchen &amp; Anfragen</h3>
<h3 data-i18n="quick.heading"></h3>
<p class="muted mono" id="quick-file"></p>
<p class="muted" id="quick-episode"></p>
<form id="quick-search-form" class="bar">
<input id="quick-q" placeholder="Titel/Serie suchen…" required />
<button type="submit">Suchen</button>
<input id="quick-q" data-i18n-placeholder="quick.placeholder" required />
<button type="submit" data-i18n="common.search"></button>
</form>
<label class="muted"
>Ziel-Library <select id="quick-library"></select
><span data-i18n="quick.targetLibrary"></span> <select id="quick-library"></select
></label>
<label class="muted"
>Offene Anfragen
<input id="quick-req-filter" placeholder="nach Titel, Autor, Serie filtern…" />
><span data-i18n="quick.openRequests"></span>
<input id="quick-req-filter" data-i18n-placeholder="quick.reqFilter" />
</label>
<div id="quick-scroll">
<div id="quick-open-requests"></div>
@@ -268,49 +271,92 @@
</div>
<div id="quick-foot">
<details id="quick-manual">
<summary id="quick-manual-summary">
Nicht bei Audible? Manuell anlegen
</summary>
<summary id="quick-manual-summary"></summary>
<p class="muted" id="quick-based-on" hidden></p>
<div class="quick-manual-fields">
<label>Titel <input id="quick-m-title" /></label>
<label>Autor <input id="quick-m-authors" placeholder="optional" /></label>
<label>Serie <input id="quick-m-series" placeholder="optional" /></label>
<label><span data-i18n="common.title"></span> <input id="quick-m-title" /></label>
<label><span data-i18n="common.author"></span>
<input id="quick-m-authors" data-i18n-placeholder="common.optional" /></label>
<label><span data-i18n="common.series"></span>
<input id="quick-m-series" data-i18n-placeholder="common.optional" /></label>
<label class="narrow"
>Folge/Band <input id="quick-m-volume" type="number" min="0" /></label>
><span data-i18n="quick.episodeVolume"></span>
<input id="quick-m-volume" type="number" min="0" /></label>
</div>
<button type="button" id="quick-m-submit">Anlegen &amp; verbinden</button>
<button type="button" id="quick-m-submit" data-i18n="common.createAndLink"></button>
</details>
<div class="row">
<button type="button" id="quick-close" class="secondary">
Schließen
</button>
<button type="button" id="quick-close" class="secondary" data-i18n="common.close"></button>
</div>
</div>
</dialog>
</section>
<section id="view-series" hidden>
<div class="bar">
<button type="button" id="watch-check" data-i18n="watch.check"></button>
</div>
<div id="watch-list"></div>
</section>
<section id="view-activity" hidden>
<div class="table-scroll">
<table id="history-table">
<thead>
<tr>
<th data-i18n="history.time"></th>
<th data-i18n="history.event"></th>
<th data-i18n="common.title"></th>
<th data-i18n="common.path"></th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</section>
<section id="view-system" hidden>
<h3 data-i18n="system.health"></h3>
<ul id="health-list"></ul>
<h3 data-i18n="system.version"></h3>
<p id="system-version" class="mono"></p>
<h3 data-i18n="system.logs"></h3>
<div class="table-scroll">
<table id="log-table">
<thead>
<tr>
<th data-i18n="history.time"></th>
<th data-i18n="system.level"></th>
<th data-i18n="system.source"></th>
<th data-i18n="system.message"></th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</section>
<section id="view-settings" hidden>
<h3>Libraries</h3>
<h3 data-i18n="lib.heading"></h3>
<div class="table-scroll">
<table id="lib-table">
<thead>
<tr>
<th>Name</th>
<th>Typ</th>
<th>Pfad</th>
<th>Ordner-Schema</th>
<th>Datei-Schema</th>
<th>Sprache</th>
<th data-i18n="common.name"></th>
<th data-i18n="common.type"></th>
<th data-i18n="common.path"></th>
<th data-i18n="lib.folderTemplate"></th>
<th data-i18n="lib.fileTemplate"></th>
<th data-i18n="common.language"></th>
<th></th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<h4 id="lib-form-title">Neue Library</h4>
<h4 id="lib-form-title"></h4>
<form id="lib-form" class="bar wrap">
<input name="name" placeholder="Name (z.B. Kids)" required />
<input name="name" data-i18n-placeholder="lib.namePlaceholder" required />
<select name="media_type">
<option value="ebook">Ebook</option>
<option value="audiobook">Audiobook</option>
@@ -324,29 +370,17 @@
list="path-suggestions"
autocomplete="off" />
<datalist id="path-suggestions"></datalist>
<input
name="folder_template"
placeholder="Ordner-Schema (optional)"
class="wide" />
<input
name="file_template"
placeholder="Datei-Schema (optional)"
class="wide" />
<select name="language" title="Sprache der Ausgaben in dieser Library — filtert die Metadaten-Suche">
<option value="">Sprache: egal</option>
<option value="german">Deutsch</option>
<option value="english">Englisch</option>
<input name="folder_template" data-i18n-placeholder="lib.folderPlaceholder" class="wide" />
<input name="file_template" data-i18n-placeholder="lib.filePlaceholder" class="wide" />
<select name="language" data-i18n-title="lib.languageTitle">
<option value="" data-i18n="lib.anyLanguage"></option>
<option value="german" data-i18n="lang.german"></option>
<option value="english" data-i18n="lang.english"></option>
</select>
<button type="submit" id="lib-form-submit">Anlegen</button>
<button type="button" id="lib-form-cancel" class="secondary" hidden>
Abbrechen
</button>
<button type="submit" id="lib-form-submit" data-i18n="common.create"></button>
<button type="button" id="lib-form-cancel" class="secondary" hidden data-i18n="common.cancel"></button>
</form>
<p class="muted">
Platzhalter: {Author} {Authors} {Narrator} {Narrators} {Title} {Year}
{Series} {Volume} — {Author}/{Narrator} nennen den ersten Namen,
{Authors}/{Narrators} alle. Leere Werte fallen samt Trenner weg.
</p>
<p class="muted" data-i18n="lib.placeholders"></p>
</section>
</main>
@@ -360,32 +394,40 @@
<p class="muted" id="detail-status"></p>
</div>
</div>
<label>Titel <input name="title" required /></label>
<label>Autor(en) <input name="authors" /></label>
<label>Sprecher <input name="narrator" /></label>
<label>Serie <input name="series" /></label>
<label><span data-i18n="common.title"></span> <input name="title" required /></label>
<label><span data-i18n="common.authors"></span> <input name="authors" /></label>
<label><span data-i18n="common.narrator"></span> <input name="narrator" /></label>
<label><span data-i18n="common.series"></span> <input name="series" /></label>
<div class="row">
<label>Band <input name="volume" type="number" min="0" /></label>
<label>Jahr <input name="year" type="number" /></label>
<label><span data-i18n="common.volume"></span> <input name="volume" type="number" min="0" /></label>
<label><span data-i18n="common.year"></span> <input name="year" type="number" /></label>
</div>
<label>ISBN/ASIN <input name="external_id" /></label>
<label>Library <select name="library_id" id="detail-library"></select></label>
<label><span data-i18n="detail.externalId"></span> <input name="external_id" /></label>
<label><span data-i18n="common.library"></span>
<select name="library_id" id="detail-library"></select></label>
<p class="muted mono" id="detail-path"></p>
<div class="row">
<button value="cancel" class="secondary">Abbrechen</button>
<button type="button" id="detail-retag" class="secondary" hidden>
Speichern &amp; neu taggen
</button>
<button value="cancel" class="secondary" data-i18n="common.cancel"></button>
<button type="button" id="detail-retag" class="secondary" hidden data-i18n="detail.retag"></button>
<button type="button" id="detail-relocate" class="secondary" hidden
title="Ordner und Dateinamen nach dem Schema der Library neu anlegen">
Speichern &amp; neu ablegen
</button>
<button value="ok" id="detail-save">Speichern</button>
data-i18n-title="detail.relocateTitle" data-i18n="detail.relocate"></button>
<button value="ok" id="detail-save" data-i18n="common.save"></button>
</div>
</form>
</dialog>
<dialog id="adopt-dialog">
<h3 id="adopt-heading"></h3>
<p class="muted" id="adopt-intro"></p>
<div id="adopt-list" class="series-list"></div>
<div class="row">
<button type="button" id="adopt-cancel" class="secondary" data-i18n="common.cancel"></button>
<button type="button" id="adopt-submit" data-i18n="adopt.submit"></button>
</div>
</dialog>
<div id="toast" hidden></div>
<script src="/i18n.js"></script>
<script src="/app.js"></script>
</body>
</html>
+15 -2
View File
@@ -10,6 +10,7 @@ header {
padding: 0.8rem; background: var(--panel); border-bottom: 1px solid var(--border);
}
h1 { font-size: 1.2rem; }
#app-version { color: var(--muted); font-size: 0.72rem; font-weight: 400; font-variant-numeric: tabular-nums; }
nav { display: flex; gap: 0.4rem; flex-wrap: wrap; }
nav button { background: none; border: none; color: var(--muted); padding: 0.5rem 0.9rem; cursor: pointer; border-radius: 4px; font-size: 0.95rem; }
nav button.active, nav button:hover { color: var(--text); background: var(--bg); }
@@ -47,6 +48,7 @@ button:disabled { opacity: 0.6; cursor: default; }
}
.card.clickable { cursor: pointer; transition: border-color 0.1s, background 0.1s; }
.card.clickable:hover { border-color: var(--accent); background: #262b35; }
.card.clickable:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.card img, .nocover { width: 60px; height: 90px; object-fit: cover; border-radius: 4px; flex-shrink: 0; }
.nocover { background: var(--bg); display: flex; align-items: center; justify-content: center; color: var(--muted); font-size: 1.5rem; }
.card-body { display: flex; flex-direction: column; gap: 0.25rem; min-width: 0; }
@@ -72,6 +74,17 @@ td.state { white-space: nowrap; font-size: 0.85rem; }
td.state.done { color: var(--ok); }
td.state.failed { color: #e08585; }
td.state.running { color: var(--muted); }
td.state.warn { color: var(--warn); }
.badge.warn { background: var(--warn); color: #000; }
.badge.error { background: var(--danger); }
.watch { background: var(--panel); border: 1px solid var(--border); border-radius: 6px; padding: 0.7rem 0.9rem; margin-bottom: 0.8rem; }
.watch .row:first-child { margin-top: 0; }
.watch table { margin-top: 0.5rem; }
.watch .error-text { color: #e08585; }
#health-list { list-style: none; margin: 0.5rem 0 1rem; }
#health-list li { background: var(--panel); border-left: 3px solid var(--warn); border-radius: 4px; padding: 0.5rem 0.8rem; margin: 0.3rem 0; }
#health-list li.error { border-left-color: var(--danger); }
#health-list li.ok { border-left-color: var(--ok); }
.tag { display: inline-block; margin-left: 0.4rem; padding: 0.05rem 0.4rem; border-radius: 4px; background: var(--accent); color: #fff; font-size: 0.7rem; font-family: system-ui, sans-serif; vertical-align: 0.1em; }
/* view modes (Missing/Importiert) */
@@ -102,8 +115,8 @@ td select { max-width: 340px; }
.score-none { color: var(--muted); }
.volume { width: 90px; }
/* dialogs */
dialog { background: var(--panel); color: var(--text); border: 1px solid var(--border); border-radius: 8px; padding: 1.2rem; min-width: 340px; max-width: min(640px, 92vw); }
/* dialogs; margin: auto undoes the * reset, which pins them to the top left */
dialog { margin: auto; background: var(--panel); color: var(--text); border: 1px solid var(--border); border-radius: 8px; padding: 1.2rem; min-width: 340px; max-width: min(640px, 92vw); }
dialog::backdrop { background: rgba(0,0,0,0.6); }
dialog label { display: flex; flex-direction: column; gap: 0.2rem; margin: 0.5rem 0; font-size: 0.9rem; color: var(--muted); flex: 1; }
.detail-head { display: flex; gap: 1rem; align-items: flex-start; margin-bottom: 0.5rem; }
+167
View File
@@ -25,6 +25,7 @@ GERMAN_EDITION = {
"isbn_10": None,
"release_date": "1957-03-01",
"cached_image": "https://example.invalid/hobbit-de.jpg",
"language": {"code2": "de"},
}
@@ -83,6 +84,153 @@ def test_hardcover_drops_books_without_an_edition_in_that_language(monkeypatch):
assert asyncio.run(hardcover.search("hobbit", "german")) == []
def test_hardcover_keeps_a_book_whose_editions_carry_no_language(monkeypatch):
"""German translations are often their own book row with untagged editions."""
def handler(request: httpx.Request) -> httpx.Response:
import json
if "editions" in json.loads(request.content)["query"]:
return httpx.Response(200, json={"data": {
"editions": [{"book_id": 42, "title": "Der Hobbit", "language": None}],
"other": [],
}})
return httpx.Response(200, json={"data": {"search": {"results": {"hits": [HARDCOVER_HIT]}}}})
monkeypatch.setattr(hardcover, "TOKEN", "test-token")
mock_client(monkeypatch, hardcover, handler)
hit = asyncio.run(hardcover.search("hobbit", "german"))[0]
assert (hit.title, hit.language) == ("Der Hobbit", "")
def test_hardcover_drops_an_untagged_edition_of_a_foreign_book(monkeypatch):
def handler(request: httpx.Request) -> httpx.Response:
import json
if "editions" in json.loads(request.content)["query"]:
return httpx.Response(200, json={"data": {
"editions": [{"book_id": 42, "title": "The Hobbit", "language": None}],
"other": [{"book_id": 42}],
}})
return httpx.Response(200, json={"data": {"search": {"results": {"hits": [HARDCOVER_HIT]}}}})
monkeypatch.setattr(hardcover, "TOKEN", "test-token")
mock_client(monkeypatch, hardcover, handler)
assert asyncio.run(hardcover.search("hobbit", "german")) == []
SERIES_ROWS = {
"name": "Die drei Fragezeichen",
"book_series": [
{"position": 1, "book": {
"id": 1, "title": "Super-Papagei", "release_year": 1968,
"contributions": [{"author": {"name": "Robert Arthur"}}],
"cached_image": {"url": "https://example.invalid/1.jpg"}}},
{"position": 2, "book": {"id": 2, "title": "Misteri Danau"}},
{"position": 2, "book": {"id": 3, "title": "Der Phantomsee"}},
{"position": 2, "book": {"id": 2, "title": "duplicate row"}},
{"position": 0, "book": {"id": 4, "title": "Sonderband"}},
],
}
def series_client(monkeypatch, editions):
seen = []
def handler(request: httpx.Request) -> httpx.Response:
import json
body = json.loads(request.content)
seen.append(body["variables"])
if "editions" in body["query"]:
return httpx.Response(200, json={"data": {"editions": editions}})
return httpx.Response(200, json={"data": {"series": [SERIES_ROWS]}})
monkeypatch.setattr(hardcover, "TOKEN", "test-token")
mock_client(monkeypatch, hardcover, handler)
return seen
def test_hardcover_series_sorts_by_volume_and_keeps_the_first_row(monkeypatch):
series_client(monkeypatch, [])
books = asyncio.run(hardcover.series_books("10146"))
assert [(b.volume, b.title) for b in books] == [
(1, "Super-Papagei"), (2, "Misteri Danau"), (None, "Sonderband"),
]
first = books[0]
assert (first.series, first.series_id, first.authors, first.media_type) == (
"Die drei Fragezeichen", "10146", "Robert Arthur", "ebook")
assert first.cover_url == "https://example.invalid/1.jpg"
def test_hardcover_series_prefers_the_row_with_an_edition_in_the_language(monkeypatch):
calls = series_client(monkeypatch, [
{"book_id": 3, "title": "Der Phantomsee", "isbn_13": "9783440032251",
"release_date": "1969-01-01", "cached_image": None,
"language": {"code2": "de"}},
])
books = asyncio.run(hardcover.series_books("10146", "german"))
assert calls[1] == {"ids": [1, 2, 3, 4], "lang": "de"}
second = next(b for b in books if b.volume == 2)
assert (second.title, second.language, second.external_id, second.year) == (
"Der Phantomsee", "german", "9783440032251", 1969)
# a volume without a German edition keeps its original row instead of vanishing
assert [b.title for b in books] == ["Super-Papagei", "Der Phantomsee", "Sonderband"]
INTRUDER_ROWS = {
"name": "Villains",
"book_series": [
{"position": 1, "book": {
"id": 1, "title": "Vicious", "users_count": 1833,
"contributions": [{"author": {"name": "V. E. Schwab"}}]}},
{"position": 1, "book": {
"id": 2, "title": "Die Schoenste im ganzen Land", "users_count": 1,
"contributions": [{"author": {"name": "Serena Valentino"}}]}},
{"position": 2, "book": {
"id": 3, "title": "Coeur de pierre", "users_count": 1,
"contributions": [{"author": {"name": "Serena Valentino"}}]}},
{"position": 2, "book": {
"id": 4, "title": "Cold Hearted", "users_count": 76,
"contributions": [{"author": {"name": "Serena Valentino"}}]}},
],
}
def test_hardcover_series_skips_a_foreign_book_filed_under_a_volume(monkeypatch):
"""Hardcover lets anyone file a book into a series - "Vicious" sits on volume
1 of the Disney Villains and has a German edition and 1833 readers, so both
tie breakers would hand it the slot."""
def handler(request: httpx.Request) -> httpx.Response:
import json
if "editions" in json.loads(request.content)["query"]:
return httpx.Response(200, json={"data": {"editions": [
{"book_id": 1, "title": "Vicious - Das Boese in uns"},
{"book_id": 2, "title": "Die Schoenste im ganzen Land"},
]}})
return httpx.Response(200, json={"data": {"series": [INTRUDER_ROWS]}})
monkeypatch.setattr(hardcover, "TOKEN", "test-token")
mock_client(monkeypatch, hardcover, handler)
books = asyncio.run(hardcover.series_books("976", "german"))
# volume 2 has no German edition: the most read row beats the French one
assert [b.title for b in books] == [
"Die Schoenste im ganzen Land", "Cold Hearted"]
def test_hardcover_series_survives_a_broken_edition_lookup(monkeypatch):
def handler(request: httpx.Request) -> httpx.Response:
import json
if "editions" in json.loads(request.content)["query"]:
return httpx.Response(500)
return httpx.Response(200, json={"data": {"series": [SERIES_ROWS]}})
monkeypatch.setattr(hardcover, "TOKEN", "test-token")
mock_client(monkeypatch, hardcover, handler)
assert len(asyncio.run(hardcover.series_books("10146", "german"))) == 3
def test_hardcover_series_needs_a_numeric_id(monkeypatch):
monkeypatch.setattr(hardcover, "TOKEN", "test-token")
assert asyncio.run(hardcover.series_books("")) == []
def test_google_books_restricts_the_language(monkeypatch):
seen = []
@@ -166,3 +314,22 @@ def test_automatic_search_asks_only_the_best_provider(monkeypatch):
monkeypatch.setattr(openlibrary, "search", lambda q, lang="", fb=True: record("ol", q, lang))
asyncio.run(metadata.ebook_search("hobbit", "german", fallback=False))
assert called == ["gb"] # no token, so Google Books stands in for Hardcover
def test_hardcover_puts_hits_of_unknown_language_last(monkeypatch):
untagged = {"document": {**HARDCOVER_HIT["document"], "id": "7", "title": "Bundle"}}
def handler(request: httpx.Request) -> httpx.Response:
import json
if "editions" in json.loads(request.content)["query"]:
return httpx.Response(200, json={"data": {"editions": [
{"book_id": 7, "title": "Bundle", "language": None},
GERMAN_EDITION,
]}})
return httpx.Response(200, json={"data": {"search": {"results": {
"hits": [untagged, HARDCOVER_HIT]}}}})
monkeypatch.setattr(hardcover, "TOKEN", "test-token")
mock_client(monkeypatch, hardcover, handler)
hits = asyncio.run(hardcover.search("hobbit", "german"))
assert [h.title for h in hits] == ["Der Hobbit", "Bundle"]
+289
View File
@@ -753,6 +753,8 @@ def test_relocate_after_fixing_the_series(client):
# a cover dropped next to the audio must survive the move
(Path(dest) / "cover.jpg").write_bytes(b"")
inode = Path(dest).stat().st_ino
client.put(f"/api/requests/{req_id}", json={
"library_id": lib_id, "title": "Philosopher's Stone",
"series": "Harry Potter", "volume": 1,
@@ -761,6 +763,8 @@ def test_relocate_after_fixing_the_series(client):
assert res["ok"] and res["moved"]
new = root / "Harry Potter" / "Philosopher's Stone"
assert Path(res["dest"]) == new
# a renamed folder, not a new one: Audiobookshelf keeps the item and its cover
assert new.stat().st_ino == inode
assert sorted(p.name for p in new.iterdir()) == [
"Philosopher's Stone - Part 01.mp3", "Philosopher's Stone - Part 02.mp3", "cover.jpg",
]
@@ -769,6 +773,45 @@ def test_relocate_after_fixing_the_series(client):
"imported_path"] == str(new)
def test_the_folder_series_replaces_the_requests_on_import(client):
"""Hardcover counts "Tote Augen" as Will Trent 3, the German release as Georgia 1."""
root = client.tmp_path / "library" / "german"
lib_id = client.post("/api/libraries", json={
"name": "german", "media_type": "audiobook", "root_path": str(root),
"folder_template": "{Series}/{Volume} - {Title}", "file_template": "{Title}",
}).json()["id"]
req_id = client.post("/api/requests", json={
"library_id": lib_id, "title": "Tote Augen", "authors": "Karin Slaughter",
"series": "Will Trent", "volume": 3,
}).json()["id"]
def import_folder(name, **override):
src = client.downloads / name
src.mkdir()
(src / "01.mp3").write_bytes(b"")
return client.post("/api/import", json={"items": [{
"path": str(src), "is_dir": True, "files": [str(src / "01.mp3")],
"request_id": req_id, **override,
}]}).json()["results"][0]
# a failed import must not leave the folder's series behind
(root / "Georgia" / "01 - Tote Augen").mkdir(parents=True)
(root / "Georgia" / "01 - Tote Augen" / "Tote Augen.mp3").write_bytes(b"")
failed = import_folder("broken", series="Georgia", volume=1)
assert not failed["ok"]
req = client.get("/api/requests", params={"status": "missing"}).json()[0]
assert (req["series"], req["volume"]) == ("Will Trent", 3)
shutil.rmtree(root / "Georgia")
result = import_folder("Karin Slaughter - Georgia 01 - Tote Augen",
series="Georgia", volume=1)
assert result["ok"], result
assert Path(result["dest"]).parent == root / "Georgia" / "01 - Tote Augen"
req = client.get("/api/requests", params={"status": "imported"}).json()[0]
assert (req["series"], req["volume"]) == ("Georgia", 1)
def test_relocate_is_a_no_op_when_nothing_changed(client):
root = client.tmp_path / "library" / "l"
lib_id = client.post("/api/libraries", json={
@@ -1135,3 +1178,249 @@ def test_extra_ebook_extensions_are_configurable(client, monkeypatch):
finally:
monkeypatch.delenv("WORDARR_EXTRA_EBOOK_EXTENSIONS")
importlib.reload(config_module)
def test_version_endpoint_reports_a_build_stamp(client):
data = client.get("/api/version").json()
assert data["version"]
# "2026-09-01 15:43" - the newest source file, so a stale deploy stands out
assert len(data["built_at"]) == 16 and data["built_at"][4] == "-"
def test_history_records_what_happened_to_a_request(client):
root = client.tmp_path / "library" / "hist"
lib_id = client.post("/api/libraries", json={
"name": "hist", "media_type": "ebook", "root_path": str(root),
"folder_template": "{Title}", "file_template": "{Title}",
}).json()["id"]
req_id = client.post("/api/requests", json={
"library_id": lib_id, "title": "Momo"}).json()["id"]
gone = client.downloads / "gone.epub"
res = client.post("/api/import", json={"items": [{
"path": str(gone), "is_dir": False, "files": [str(gone)], "request_id": req_id,
}]}).json()["results"][0]
assert not res["ok"]
src = client.downloads / "Momo.epub"
src.write_bytes(b"x")
dest = client.post("/api/import", json={"items": [{
"path": str(src), "is_dir": False, "files": [str(src)], "request_id": req_id,
}]}).json()["results"][0]["dest"]
client.put(f"/api/requests/{req_id}", json={"library_id": lib_id, "title": "Momo 2"})
assert client.post(f"/api/requests/{req_id}/relocate").json()["moved"]
# a relocate that moves nothing is not worth a row
client.post(f"/api/requests/{req_id}/relocate")
client.delete(f"/api/requests/{req_id}")
rows = client.get("/api/history").json()
assert [r["event"] for r in rows] == ["deleted", "relocated", "imported", "failed"]
assert all(r["request_id"] == req_id for r in rows)
assert rows[0]["title"] == "Momo 2" # survives the deleted request
assert rows[2]["dest"] == dest
assert rows[3]["message"]
assert rows[0]["at"].endswith("+00:00")
def test_connections_hear_about_committed_rows(client, monkeypatch):
import httpx
from wordarr import connect
calls = []
def handler(request):
calls.append((request.method, str(request.url)))
if request.url.path == "/api/libraries":
return httpx.Response(200, json={"libraries": [
{"id": "lib-audio", "name": "Audio",
"folders": [{"fullPath": str(client.tmp_path / "library")}]},
{"id": "lib-pod", "name": "Pod", "folders": [{"fullPath": "/podcasts"}]},
]})
if request.url.host == "hook":
return httpx.Response(500) # a broken webhook must not stop the scan
return httpx.Response(200)
real_client = httpx.Client
monkeypatch.setattr(connect.httpx, "Client",
lambda **kw: real_client(transport=httpx.MockTransport(handler), **kw))
monkeypatch.setattr(connect, "WEBHOOK_URL", "http://hook/in")
monkeypatch.setattr(connect, "ABS_URL", "http://abs")
monkeypatch.setattr(connect, "ABS_TOKEN", "t")
root = client.tmp_path / "library" / "conn"
lib_id = client.post("/api/libraries", json={
"name": "conn", "media_type": "ebook", "root_path": str(root),
"folder_template": "{Title}", "file_template": "{Title}",
}).json()["id"]
req_id = client.post("/api/requests", json={
"library_id": lib_id, "title": "Momo"}).json()["id"]
src = client.downloads / "Momo.epub"
src.write_bytes(b"x")
assert client.post("/api/import", json={"items": [{
"path": str(src), "is_dir": False, "files": [str(src)], "request_id": req_id,
}]}).json()["results"][0]["ok"]
assert calls == [
("POST", "http://hook/in"),
("GET", "http://abs/api/libraries"),
("POST", "http://abs/api/libraries/lib-audio/scan"),
]
calls.clear()
client.delete(f"/api/requests/{req_id}")
assert calls == [("POST", "http://hook/in")] # nothing new on disk, no scan
def test_bulk_move_delete_and_count(client):
def lib(name, media_type):
return client.post("/api/libraries", json={
"name": name, "media_type": media_type,
"root_path": str(client.tmp_path / name)}).json()["id"]
a, b, audio = lib("a", "ebook"), lib("b", "ebook"), lib("audio", "audiobook")
ids = [client.post("/api/requests", json={"library_id": a, "title": f"T{i}"}).json()["id"]
for i in range(3)]
assert client.get("/api/requests/count?status=missing").json() == {"count": 3}
assert client.put("/api/requests/bulk", json={
"ids": ids[:2], "library_id": audio}).status_code == 400
assert client.put("/api/requests/bulk", json={
"ids": ids[:2], "library_id": b}).json() == {"updated": 2}
assert [r["id"] for r in client.get(f"/api/requests?library_id={b}").json()] \
== sorted(ids[:2], reverse=True)
assert client.request("DELETE", "/api/requests/bulk",
json={"ids": ids[1:]}).json() == {"deleted": 2}
assert client.get("/api/requests/count?status=missing").json() == {"count": 1}
assert [r["event"] for r in client.get("/api/history").json()] == ["deleted"] * 2
def test_adopt_files_already_in_the_library(client):
root = client.tmp_path / "audio"
lib = client.post("/api/libraries", json={
"name": "Audio", "media_type": "audiobook", "root_path": str(root)}).json()["id"]
hobbit = client.post("/api/requests", json={
"library_id": lib, "title": "Der Hobbit", "authors": "Tolkien"}).json()["id"]
known = client.post("/api/requests", json={
"library_id": lib, "title": "Momo", "authors": "Ende"}).json()["id"]
for folder in ("Tolkien/Der Hobbit", "Ende/Momo"):
(root / folder).mkdir(parents=True)
for i in (1, 2):
(root / folder / f"{i:02d}.mp3").write_bytes(b"")
(root / "Tolkien" / "Notizen.epub").write_bytes(b"") # other media type
# Momo already points at its folder, so only the Hobbit is offered
adopted = client.post(f"/api/import/library/{lib}", json={"items": [
{"path": str(root / "Ende/Momo"), "request_id": known}]}).json()
assert adopted == {"results": [{"path": str(root / "Ende/Momo"), "ok": True}]}
items = client.get(f"/api/import/library/{lib}").json()["items"]
assert [(i["name"], i["suggested_request_id"]) for i in items] == [("Der Hobbit", hobbit)]
resp = client.post(f"/api/import/library/{lib}", json={"items": [
{"path": items[0]["path"], "request_id": hobbit},
{"path": str(root / "Ende/Momo"), "request_id": known}, # no longer open
{"path": str(client.downloads), "request_id": hobbit},
]}).json()["results"]
assert [r["ok"] for r in resp] == [True, False, False]
reqs = {r["id"]: r for r in client.get("/api/requests?status=imported").json()}
assert reqs[hobbit]["imported_path"] == str(root / "Tolkien/Der Hobbit")
assert (root / "Tolkien/Der Hobbit/01.mp3").exists() # nothing moved
assert client.get(f"/api/import/library/{lib}").json()["items"] == []
assert [h["event"] for h in client.get("/api/history").json()] == ["adopted"] * 2
def test_health_reports_problems_as_codes(client, monkeypatch):
from wordarr import connect
from wordarr.metadata import hardcover
monkeypatch.setattr(hardcover, "TOKEN", "")
monkeypatch.setattr(connect, "ABS_URL", "http://abs")
monkeypatch.setattr(connect, "ABS_TOKEN", "")
gone = client.tmp_path / "gone"
client.post("/api/libraries", json={
"name": "Weg", "media_type": "ebook", "root_path": str(gone)})
client.post("/api/libraries", json={
"name": "Da", "media_type": "ebook", "root_path": str(client.downloads)})
issues = client.get("/api/system/health").json()
assert {(i["code"], i.get("name")) for i in issues} == {
("library.missing", "Weg"), ("hardcover.token", None), ("abs.incomplete", None)}
monkeypatch.setattr(hardcover, "TOKEN", "x")
monkeypatch.setattr(connect, "ABS_URL", "")
gone.mkdir()
assert client.get("/api/system/health").json() == []
def test_recent_logs_newest_first(client):
import logging
logging.getLogger("wordarr.test").info("first")
logging.getLogger("wordarr.test").warning("second")
rows = client.get("/api/system/logs").json()
assert [r["message"] for r in rows[:2]] == ["second", "first"]
assert rows[0]["level"] == "WARNING"
def test_watched_series_suggests_only_new_parts(client, monkeypatch):
import asyncio
import datetime
from wordarr import db
from wordarr.api import series
from wordarr.metadata import SERIES_PROVIDERS
from wordarr.metadata.base import MetadataResult
def part(volume, external_id=""):
return MetadataResult(media_type="ebook", title=f"Band {volume}", volume=volume,
series="Saga", external_id=external_id, source="hardcover")
hits = [part(1, "a1"), part(2, "a2")]
calls = []
async def lookup(series_id, title, language, source):
calls.append(series_id)
if hits is None:
raise RuntimeError("offline")
return hits
monkeypatch.setitem(SERIES_PROVIDERS, "ebook", lookup)
lib = client.post("/api/libraries", json={
"name": "Saga", "media_type": "ebook", "root_path": str(client.tmp_path)}).json()
client.post("/api/requests", json={"library_id": lib["id"], "title": "Band 3", "volume": 3})
watch = {"media_type": "ebook", "library_id": lib["id"], "series_id": "s1",
"source": "hardcover", "title": "Saga"}
resp = client.post("/api/series", json=watch)
assert resp.status_code == 200
assert resp.json()["new"] == [] # what exists today is not news
assert client.post("/api/series", json=watch).status_code == 409
hits += [part(3), part(4, "a4"), part(5, "a5")] # 3 is requested already
[w] = client.post("/api/series/check").json()
new = {p["title"]: p["id"] for p in w["new"]}
assert set(new) == {"Band 4", "Band 5"}
client.post("/api/series/parts", json={"ids": [new["Band 4"]], "action": "request"})
client.post("/api/series/parts", json={"ids": [new["Band 5"]], "action": "ignore"})
missing = client.get("/api/requests?status=missing").json()
assert {r["title"] for r in missing} == {"Band 3", "Band 4"}
assert client.post("/api/series/check").json()[0]["new"] == []
hits = None
[w] = client.post("/api/series/check").json()
assert w["error"] == "offline"
assert client.post("/api/series", json={**watch, "series_id": "s2"}).status_code == 502
# the daily run only picks up what was checked more than a day ago
hits, calls[:] = [], []
asyncio.run(series.check_due())
assert calls == []
with db.SessionLocal() as session:
session.get(db.WatchedSeries, w["id"]).checked_at = (
datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=2))
session.commit()
asyncio.run(series.check_due())
assert calls == ["s1"]
assert client.delete(f"/api/series/{w['id']}").status_code == 200
assert client.get("/api/series").json() == []
+39
View File
@@ -82,3 +82,42 @@ def test_a_broken_fallback_never_breaks_the_search(monkeypatch):
monkeypatch.setattr(metadata.musicbrainz, "search", boom)
results = asyncio.run(metadata.audiobook_search("Toteninsel"))
assert [r.title for r in results] == ["Da"]
def test_audiobook_search_appends_hardcover_series_as_audiobooks(monkeypatch):
from wordarr.metadata.base import MetadataResult
async def fake_audible(query, language=""):
return [MetadataResult(media_type="audiobook", title="Die letzte Witwe")]
async def no_hits(query, language=""):
return []
async def fake_hardcover(query, language=""):
return [MetadataResult(media_type="ebook", source="hardcover",
title="Die verstummte Frau", series="Will Trent",
series_id="3670", volume=10)]
monkeypatch.setattr(metadata.audible, "search", fake_audible)
monkeypatch.setattr(metadata.musicbrainz, "search", no_hits)
monkeypatch.setattr(metadata.hardcover, "search", fake_hardcover)
results = asyncio.run(metadata.audiobook_search("Karin Slaughter", "german"))
assert [r.source for r in results] == ["audible", "hardcover"]
assert results[1].media_type == "audiobook"
def test_audiobook_series_goes_to_the_provider_of_the_hit(monkeypatch):
from wordarr.metadata.base import MetadataResult
async def fake_series_books(series_id, language=""):
return [MetadataResult(media_type="ebook", source="hardcover",
title="Triptych", volume=1)]
async def audible_must_not_run(series_id, title):
raise AssertionError("a hardcover id is no audible series asin")
monkeypatch.setattr(metadata.hardcover, "series_books", fake_series_books)
monkeypatch.setattr(metadata.audible, "series_episodes", audible_must_not_run)
lookup = metadata.SERIES_PROVIDERS["audiobook"]
books = asyncio.run(lookup("3670", "Will Trent", "german", "hardcover"))
assert [(b.title, b.media_type) for b in books] == [("Triptych", "audiobook")]
+62 -1
View File
@@ -57,12 +57,14 @@ def server(tmp_path_factory):
@pytest.fixture
def page(server):
"""A German browser, so the UI language is the same everywhere."""
with sync_api.sync_playwright() as pw:
try:
browser = pw.chromium.launch()
except Exception as exc: # browsers not downloaded
pytest.skip(f"no chromium: {exc}")
page = browser.new_page(viewport={"width": 1200, "height": 900})
page = browser.new_page(viewport={"width": 1200, "height": 900},
locale="de-DE")
errors = []
page.on("pageerror", lambda e: errors.append(str(e)))
page.goto(server, wait_until="networkidle")
@@ -95,6 +97,8 @@ def test_import_dialog_opens_and_closes(page):
page.click("[data-quick='0']")
page.wait_for_selector("#quick-dialog[open]")
assert page.locator("#quick-dialog").is_visible()
box = page.locator("#quick-dialog").bounding_box()
assert abs(box["x"] + box["width"] / 2 - 600) < 10 # centered in the viewport
# everything reachable, nothing cut off at the bottom
for sel in ("#quick-m-submit", "#quick-close"):
box = page.locator(sel).bounding_box()
@@ -124,3 +128,60 @@ def test_format_tags_and_the_format_filter(page):
rows = page.locator("#import-table tbody tr")
assert rows.count() == 1
assert "epub" in rows.first.locator(".tag.fmt").text_content()
def test_the_browser_language_decides_and_the_toggle_switches(page, server):
assert page.locator("nav button[data-view=search]").text_content() == "Suche"
assert page.locator("#lang-toggle").text_content() == "EN"
page.click("#lang-toggle")
assert page.locator("nav button[data-view=search]").text_content() == "Search"
assert page.locator("#lang-toggle").text_content() == "DE"
assert page.get_attribute("html", "lang") == "en"
assert page.get_attribute("#search-q", "placeholder") == "Title, author or ISBN…"
page.reload(wait_until="networkidle") # the choice outlives the reload
assert page.locator("nav button[data-view=search]").text_content() == "Search"
def test_english_reaches_the_rendered_rows(page):
page.click("#lang-toggle")
page.click("nav button[data-view=import]")
page.click("#scan-btn")
page.wait_for_selector("[data-quick]")
assert "— skip —" in page.locator("#import-table tbody select").first.text_content()
assert "2.0 KB" in " ".join(page.locator("#import-table .tag.fmt").all_text_contents())
# switching back re-renders the table that is already on screen
page.click("#lang-toggle")
assert "— überspringen —" in page.locator("#import-table tbody select").first.text_content()
assert "2,0 KB" in " ".join(page.locator("#import-table .tag.fmt").all_text_contents())
def test_the_header_shows_the_running_version(page):
stamp = page.locator("#app-version").text_content()
assert stamp.startswith("v")
assert len(stamp) > 3 # version plus the build stamp behind it
def test_the_view_survives_reload_and_back(page, server):
page.click("nav button[data-view=missing]")
assert page.url.endswith("#missing")
page.reload(wait_until="networkidle")
assert page.locator("#view-missing").is_visible()
assert not page.locator("#view-search").is_visible()
page.click("nav button[data-view=settings]")
page.go_back()
assert page.locator("#view-missing").is_visible()
assert page.locator("nav button[data-view=missing]").get_attribute("class") == "active"
def test_a_card_opens_from_the_keyboard(page, server, tmp_path):
lib = page.request.post(f"{server}/api/libraries", data={
"name": "Tastatur", "media_type": "ebook", "root_path": str(tmp_path)}).json()
page.request.post(f"{server}/api/requests", data={
"title": "Zyxw Tastaturprobe", "library_id": lib["id"]})
page.click("nav button[data-view=missing]")
card = page.locator("[data-detail]", has_text="Zyxw Tastaturprobe")
card.focus()
page.keyboard.press("Enter")
assert page.locator("#detail-dialog").is_visible()
+36
View File
@@ -0,0 +1,36 @@
import datetime
from fastapi import APIRouter, Depends
from pydantic import BaseModel, ConfigDict, field_serializer
from sqlalchemy import select
from sqlalchemy.orm import Session
from ..db import History, get_session
router = APIRouter(prefix="/api/history", tags=["history"])
class HistoryOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
at: datetime.datetime
event: str
request_id: int | None
title: str
media_type: str
source: str
dest: str
message: str
# SQLite drops the zone, and a bare timestamp reads as local time in JS
@field_serializer("at")
def _utc(self, at: datetime.datetime) -> str:
return at.replace(tzinfo=datetime.UTC).isoformat()
@router.get("", response_model=list[HistoryOut])
def list_history(limit: int = 200, session: Session = Depends(get_session)):
"""Newest first."""
stmt = select(History).order_by(History.id.desc()).limit(min(limit, 1000))
return session.scalars(stmt).all()
+102 -6
View File
@@ -1,13 +1,13 @@
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.orm import Session
from .. import config
from ..db import BookRequest, get_session
from ..fsnames import decode_path, readable
from .. import config, connect
from ..db import BookRequest, Library, get_session, record
from ..fsnames import decode_path, is_broken, readable
from ..importer import matcher, mover, scanner
router = APIRouter(prefix="/api/import", tags=["import"])
@@ -47,6 +47,10 @@ class ImportItem(BaseModel):
request_id: int
# add to an already imported audiobook instead of importing a new one
append: bool = False
# series and volume the download folder names; they win over the request's,
# since translations number their series differently from the metadata APIs
series: str = ""
volume: int | None = None
class ImportIn(BaseModel):
@@ -54,8 +58,9 @@ class ImportIn(BaseModel):
@router.post("")
def do_import(data: ImportIn, session: Session = Depends(get_session)):
results = []
def do_import(data: ImportIn, background: BackgroundTasks,
session: Session = Depends(get_session)):
results, rows = [], []
download_root = Path(config.DOWNLOAD_DIR).resolve()
for item in data.items:
# the client hands back whatever the scan produced, base64 included
@@ -87,9 +92,12 @@ def do_import(data: ImportIn, session: Session = Depends(get_session)):
try:
dest = mover.append_to_import(item.files, req, req.library)
except Exception as exc:
rows.append(record(session, "failed", req, source=shown, message=str(exc)))
session.commit()
results.append({"path": shown, "ok": False, "error": str(exc)})
continue
req.imported_path = dest
rows.append(record(session, "appended", req, source=shown, dest=dest))
session.commit()
results.append({"path": shown, "ok": True, "dest": dest, "appended": True})
continue
@@ -101,15 +109,103 @@ def do_import(data: ImportIn, session: Session = Depends(get_session)):
" — mehrere Einträge auf dieselbe Anfrage? Dann vorher zusammenfassen."
)})
continue
if item.series.strip() and item.volume is not None:
req.series, req.volume = item.series.strip(), item.volume
try:
dest = mover.import_item(item.path, item.files, item.is_dir, req, req.library)
except Exception as exc:
session.rollback() # the next commit must not keep the folder's series
rows.append(record(session, "failed", req, source=shown, message=str(exc)))
session.commit()
results.append({"path": shown, "ok": False, "error": str(exc)})
continue
req.status = "imported"
req.imported_path = dest
rows.append(record(session, "imported", req, source=shown, dest=dest))
session.commit()
results.append({"path": shown, "ok": True, "dest": dest})
if not results:
raise HTTPException(400, "nothing to import")
connect.schedule(background, rows)
return {"results": results}
def _library(session: Session, library_id: int) -> Library:
lib = session.get(Library, library_id)
if not lib:
raise HTTPException(404, "library not found")
return lib
def _open_for(session: Session, lib: Library) -> list[BookRequest]:
missing = session.scalars(select(BookRequest).where(
BookRequest.status == "missing", BookRequest.library_id == lib.id)).all()
return list(missing) + [r for r in _orphaned(session) if r.library_id == lib.id]
@router.get("/library/{library_id}")
def scan_library(library_id: int, session: Session = Depends(get_session)):
"""Files already in the library that no request points at, matched against
the library's open requests, so an existing collection need not be imported
again."""
lib = _library(session, library_id)
imported = session.scalars(select(BookRequest.imported_path).where(
BookRequest.status == "imported", BookRequest.imported_path != "")).all()
covered = {Path(p) for p in imported}
# a folder holding an imported file, or a file inside an imported folder
above = {a for p in covered for a in p.parents}
def known(item: dict) -> bool:
p = Path(decode_path(item["path"]))
return p in covered or p in above or any(a in covered for a in p.parents)
items = [i for i in scanner.scan(Path(lib.root_path))
if i["media_type"] == lib.media_type and not known(i)]
open_reqs = _open_for(session, lib)
return {"root_path": lib.root_path,
"items": matcher.best_matches(items, open_reqs),
"requests": [{"id": r.id, "title": r.title, "authors": r.authors}
for r in open_reqs]}
class AdoptItem(BaseModel):
path: str
request_id: int
class AdoptIn(BaseModel):
items: list[AdoptItem]
@router.post("/library/{library_id}")
def adopt(library_id: int, data: AdoptIn, background: BackgroundTasks,
session: Session = Depends(get_session)):
"""Point open requests at files that are already in place. Nothing moves."""
lib = _library(session, library_id)
root = Path(lib.root_path).resolve()
open_ids = {r.id for r in _open_for(session, lib)}
results, rows = [], []
for item in data.items:
path = decode_path(item.path)
shown = readable(path)
req = session.get(BookRequest, item.request_id)
if is_broken(path):
error = "file name is not valid UTF-8, rename it first"
elif root not in Path(path).resolve().parents:
error = "path outside the library"
elif not Path(path).exists():
error = "path no longer exists"
elif not req or req.id not in open_ids:
error = f"request #{item.request_id} is not open in this library"
else:
error = ""
if error:
results.append({"path": shown, "ok": False, "error": error})
continue
req.status, req.imported_path = "imported", path
rows.append(record(session, "adopted", req, dest=path))
open_ids.discard(req.id) # two files for one request: the first wins
results.append({"path": shown, "ok": True})
session.commit()
connect.schedule(background, rows)
return {"results": results}
+2 -3
View File
@@ -1,5 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict
from sqlalchemy import select
from sqlalchemy.orm import Session
@@ -21,8 +21,7 @@ class LibraryIn(BaseModel):
class LibraryOut(LibraryIn):
id: int
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
def _validate(data: LibraryIn):
+67 -10
View File
@@ -1,12 +1,12 @@
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from pydantic import BaseModel, ConfigDict
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from .. import config
from ..db import BookRequest, Library, get_session
from .. import config, connect
from ..db import BookRequest, Library, get_session, record
from ..importer import mover, tagger
router = APIRouter(prefix="/api/requests", tags=["requests"])
@@ -34,8 +34,7 @@ class RequestOut(RequestIn):
imported_path: str
library_name: str = ""
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
def _out(r: BookRequest) -> RequestOut:
@@ -177,6 +176,51 @@ def create_bulk_items(data: BulkItemsIn, session: Session = Depends(get_session)
return {"created": [_out(r) for r in created], "skipped": skipped}
@router.get("/count")
def count_requests(status: str | None = None, session: Session = Depends(get_session)):
stmt = select(func.count()).select_from(BookRequest)
if status:
stmt = stmt.where(BookRequest.status == status)
return {"count": session.scalar(stmt)}
class BulkIdsIn(BaseModel):
ids: list[int]
class BulkUpdateIn(BulkIdsIn):
library_id: int
# registered before the /{request_id} routes, which would try "bulk" as an id
@router.put("/bulk")
def update_bulk(data: BulkUpdateIn, session: Session = Depends(get_session)):
"""Move requests to another library. Imported files stay where they are
until the request is relocated."""
lib = session.get(Library, data.library_id)
if not lib:
raise HTTPException(404, "library not found")
reqs = session.scalars(select(BookRequest).where(BookRequest.id.in_(data.ids))).all()
if any(r.media_type != lib.media_type for r in reqs):
raise HTTPException(400, "library has a different media type")
for r in reqs:
r.library_id = lib.id
session.commit()
return {"updated": len(reqs)}
@router.delete("/bulk")
def delete_bulk(data: BulkIdsIn, background: BackgroundTasks,
session: Session = Depends(get_session)):
reqs = session.scalars(select(BookRequest).where(BookRequest.id.in_(data.ids))).all()
rows = [record(session, "deleted", r, source=r.imported_path) for r in reqs]
for r in reqs:
session.delete(r)
session.commit()
connect.schedule(background, rows)
return {"deleted": len(reqs)}
@router.put("/{request_id}", response_model=RequestOut)
def update_request(request_id: int, data: RequestIn, session: Session = Depends(get_session)):
req = session.get(BookRequest, request_id)
@@ -194,7 +238,8 @@ def update_request(request_id: int, data: RequestIn, session: Session = Depends(
@router.post("/{request_id}/retag")
def retag_request(request_id: int, session: Session = Depends(get_session)):
def retag_request(request_id: int, background: BackgroundTasks,
session: Session = Depends(get_session)):
"""Rewrite the audio tags of an already imported request from its current
metadata (e.g. after adding series/volume/narrator later)."""
req = session.get(BookRequest, request_id)
@@ -214,15 +259,22 @@ def retag_request(request_id: int, session: Session = Depends(get_session)):
raise HTTPException(404, f"no audio files in {path}")
for i, f in enumerate(files, 1):
tagger.tag_audio(f, req, track=i, total=len(files))
row = record(session, "retagged", req, dest=str(path))
session.commit()
connect.schedule(background, [row])
return {"ok": True, "files": len(files)}
if path.is_file():
tagger.tag_audio(path, req)
row = record(session, "retagged", req, dest=str(path))
session.commit()
connect.schedule(background, [row])
return {"ok": True, "files": 1}
raise HTTPException(404, f"imported path no longer exists: {path}")
@router.post("/{request_id}/relocate")
def relocate_request(request_id: int, session: Session = Depends(get_session)):
def relocate_request(request_id: int, background: BackgroundTasks,
session: Session = Depends(get_session)):
"""Re-apply the library's naming scheme to an imported title, e.g. after
fixing a series that Audible had filed differently."""
req = session.get(BookRequest, request_id)
@@ -238,15 +290,20 @@ def relocate_request(request_id: int, session: Session = Depends(get_session)):
except FileExistsError as exc:
raise HTTPException(409, str(exc))
req.imported_path = dest
rows = [record(session, "relocated", req, source=old, dest=dest)] if dest != old else []
session.commit()
connect.schedule(background, rows)
return {"ok": True, "moved": dest != old, "from": old, "dest": dest}
@router.delete("/{request_id}")
def delete_request(request_id: int, session: Session = Depends(get_session)):
def delete_request(request_id: int, background: BackgroundTasks,
session: Session = Depends(get_session)):
req = session.get(BookRequest, request_id)
if not req:
raise HTTPException(404, "request not found")
row = record(session, "deleted", req, source=req.imported_path)
session.delete(req)
session.commit()
connect.schedule(background, [row])
return {"ok": True}
+10 -6
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter, HTTPException
from ..metadata import PROVIDERS, audible
from ..metadata import PROVIDERS, SERIES_PROVIDERS
from ..metadata.base import MetadataResult
router = APIRouter(prefix="/api/search", tags=["search"])
@@ -18,13 +18,17 @@ async def search(media_type: str, q: str, language: str = "", fallback: bool = T
@router.get("/series", response_model=list[MetadataResult])
async def series(series_id: str, title: str):
"""All episodes of an Audible series, sorted by episode number. Audible has
no series endpoint, so this is a paged keyword crawl and may miss a few
episodes - those can still be added by hand."""
async def series(series_id: str, title: str = "", media_type: str = "audiobook",
language: str = "", source: str = ""):
"""All parts of a series, by number. Audible has no series endpoint, so
audiobooks are a keyword crawl that may miss episodes; Hardcover is exact.
The source picks the provider, a series id only means something to its own."""
lookup = SERIES_PROVIDERS.get(media_type)
if not lookup:
raise HTTPException(400, f"no series lookup for media_type: {media_type}")
if not series_id:
raise HTTPException(400, "series_id required")
try:
return await audible.series_episodes(series_id, title)
return await lookup(series_id, title, language, source)
except Exception as exc:
raise HTTPException(502, f"metadata provider error: {exc}")
+164
View File
@@ -0,0 +1,164 @@
import asyncio
import datetime
import logging
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import or_, select
from sqlalchemy.orm import Session
from .. import db
from ..db import BookRequest, Library, SeriesPart, WatchedSeries, get_session
from ..metadata import SERIES_PROVIDERS
router = APIRouter(prefix="/api/series", tags=["series"])
log = logging.getLogger(__name__)
PART_FIELDS = ("title", "authors", "narrator", "external_id", "year", "series",
"volume", "cover_url")
def _now() -> datetime.datetime:
return datetime.datetime.now(datetime.UTC)
def _key(r) -> str:
return r.external_id or f"{r.title.strip().lower()}|{r.volume}"
async def check_watch(session: Session, w: WatchedSeries) -> None:
"""Look the series up again. The first lookup only records what exists, so
just later parts become suggestions; so do parts nobody has requested yet."""
try:
hits = await SERIES_PROVIDERS[w.media_type](w.series_id, w.title, w.language, w.source)
except Exception as exc:
log.warning("series %r lookup failed: %s", w.title, exc)
w.error, w.checked_at = str(exc), _now()
return
initial = not w.parts
reqs = session.scalars(select(BookRequest).where(BookRequest.media_type == w.media_type)).all()
requested = {r.external_id for r in reqs if r.external_id}
requested |= {(r.title.strip().lower(), r.volume) for r in reqs}
seen = {p.key for p in w.parts}
for hit in hits:
if (key := _key(hit)) in seen:
continue
seen.add(key)
known = initial or hit.external_id in requested or (
hit.title.strip().lower(), hit.volume) in requested
w.parts.append(SeriesPart(key=key, state="known" if known else "new",
**{f: getattr(hit, f) for f in PART_FIELDS}))
w.error, w.checked_at = "", _now()
async def check_due() -> None:
cutoff = (_now() - datetime.timedelta(days=1)).replace(tzinfo=None) # SQLite stores naive
with db.SessionLocal() as session:
due = session.scalars(select(WatchedSeries).where(
or_(WatchedSeries.checked_at.is_(None), WatchedSeries.checked_at < cutoff))).all()
for w in due:
await check_watch(session, w)
session.commit()
if due:
log.info("checked %d watched series", len(due))
async def run_daily() -> None:
"""Wakes hourly and checks what is a day old, so restarts do not reset the clock."""
while True:
try:
await check_due()
except Exception:
log.exception("series check failed")
await asyncio.sleep(3600)
def _out(w: WatchedSeries) -> dict:
checked = w.checked_at and w.checked_at.replace(tzinfo=datetime.UTC).isoformat()
return {
"id": w.id, "media_type": w.media_type, "library_id": w.library_id,
"library_name": w.library.name, "series_id": w.series_id, "source": w.source,
"title": w.title, "checked_at": checked, "error": w.error,
"new": [{"id": p.id, **{f: getattr(p, f) for f in PART_FIELDS}}
for p in w.parts if p.state == "new"],
}
def _all(session: Session) -> list[dict]:
return [_out(w) for w in session.scalars(select(WatchedSeries).order_by(WatchedSeries.title))]
@router.get("")
def list_watches(session: Session = Depends(get_session)):
return _all(session)
class WatchIn(BaseModel):
media_type: str
library_id: int
series_id: str
source: str = ""
title: str
language: str = ""
@router.post("")
async def create_watch(data: WatchIn, session: Session = Depends(get_session)):
if data.media_type not in SERIES_PROVIDERS:
raise HTTPException(400, f"no series lookup for media_type: {data.media_type}")
lib = session.get(Library, data.library_id)
if not lib:
raise HTTPException(404, "library not found")
if lib.media_type != data.media_type:
raise HTTPException(400, "library has a different media type")
if session.scalar(select(WatchedSeries).where(
WatchedSeries.media_type == data.media_type,
WatchedSeries.source == data.source,
WatchedSeries.series_id == data.series_id)):
raise HTTPException(409, "series is already watched")
w = WatchedSeries(**data.model_dump())
session.add(w)
await check_watch(session, w)
if w.error: # without a first lookup every part would turn up as new later
session.rollback()
raise HTTPException(502, f"metadata provider error: {w.error}")
session.commit()
return _out(w)
@router.delete("/{watch_id}")
def delete_watch(watch_id: int, session: Session = Depends(get_session)):
w = session.get(WatchedSeries, watch_id)
if not w:
raise HTTPException(404, "series not found")
session.delete(w)
session.commit()
return {"ok": True}
@router.post("/check")
async def check_all(session: Session = Depends(get_session)):
for w in session.scalars(select(WatchedSeries)).all():
await check_watch(session, w)
session.commit()
return _all(session)
class PartsIn(BaseModel):
ids: list[int]
action: str # request | ignore
@router.post("/parts")
def handle_parts(data: PartsIn, session: Session = Depends(get_session)):
if data.action not in ("request", "ignore"):
raise HTTPException(400, f"invalid action: {data.action}")
parts = session.scalars(select(SeriesPart).where(
SeriesPart.id.in_(data.ids), SeriesPart.state == "new")).all()
for p in parts:
if data.action == "request":
session.add(BookRequest(media_type=p.watch.media_type, library_id=p.watch.library_id,
**{f: getattr(p, f) for f in PART_FIELDS}))
p.state = "requested" if data.action == "request" else "ignored"
session.commit()
return {"done": len(parts)}
+44
View File
@@ -0,0 +1,44 @@
import os
from pathlib import Path
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.orm import Session
from .. import config, connect, logs
from ..db import Library, get_session
from ..metadata import hardcover
router = APIRouter(prefix="/api/system", tags=["system"])
def _dir_problem(path: Path) -> str | None:
if not path.is_dir():
return "missing"
if not os.access(path, os.W_OK | os.X_OK):
return "readonly"
return None
@router.get("/health")
def health(session: Session = Depends(get_session)):
"""Problems only, as codes the UI translates; an empty list means healthy."""
issues = []
if problem := _dir_problem(config.DOWNLOAD_DIR):
issues.append({"code": f"download.{problem}", "level": "error",
"path": str(config.DOWNLOAD_DIR)})
for lib in session.scalars(select(Library).order_by(Library.name)):
if problem := _dir_problem(Path(lib.root_path)):
issues.append({"code": f"library.{problem}", "level": "error",
"name": lib.name, "path": lib.root_path})
if not hardcover.TOKEN:
issues.append({"code": "hardcover.token", "level": "warning"})
if bool(connect.ABS_URL) != bool(connect.ABS_TOKEN):
issues.append({"code": "abs.incomplete", "level": "warning"})
return issues
@router.get("/logs")
def recent_logs():
"""Newest first."""
return list(reversed(logs.RECORDS))
+60
View File
@@ -0,0 +1,60 @@
"""Tell other services about history rows once they are committed: a generic
webhook gets every row, Audiobookshelf a scan of the libraries holding new
files. Runs as a background task, so a slow or dead service never fails the
request that caused it; errors only reach the log."""
import logging
import os
import httpx
from fastapi import BackgroundTasks
from .db import History
ABS_URL = os.environ.get("WORDARR_ABS_URL", "").strip().rstrip("/")
ABS_TOKEN = os.environ.get("WORDARR_ABS_TOKEN", "").strip()
WEBHOOK_URL = os.environ.get("WORDARR_WEBHOOK_URL", "").strip()
log = logging.getLogger(__name__)
def schedule(background: BackgroundTasks, rows: list[History]) -> None:
"""Queue the notification; it runs after the response is sent."""
events = [
{"event": r.event, "request_id": r.request_id, "title": r.title,
"media_type": r.media_type, "source": r.source, "dest": r.dest,
"message": r.message}
for r in rows
]
if events:
background.add_task(notify, events)
def notify(events: list[dict]) -> None:
with httpx.Client(timeout=10) as client:
if WEBHOOK_URL:
for e in events:
try:
client.post(WEBHOOK_URL, json=e).raise_for_status()
except httpx.HTTPError as exc:
log.warning("webhook %s failed: %s", WEBHOOK_URL, exc)
dests = [e["dest"] for e in events if e["dest"] and e["event"] != "failed"]
if ABS_URL and ABS_TOKEN and dests:
try:
_scan_audiobookshelf(client, dests)
except httpx.HTTPError as exc:
log.warning("audiobookshelf scan failed: %s", exc)
def _scan_audiobookshelf(client: httpx.Client, dests: list[str]) -> None:
"""Scan only the libraries whose folders contain a destination. That needs
both containers to mount the media under the same path."""
headers = {"Authorization": f"Bearer {ABS_TOKEN}"}
resp = client.get(f"{ABS_URL}/api/libraries", headers=headers)
resp.raise_for_status()
for lib in resp.json().get("libraries") or []:
folders = [str(f.get("fullPath") or "").rstrip("/")
for f in lib.get("folders") or []]
if any(f and (d == f or d.startswith(f + "/")) for f in folders for d in dests):
client.post(f"{ABS_URL}/api/libraries/{lib.get('id')}/scan",
headers=headers).raise_for_status()
log.info("audiobookshelf scans library %s", lib.get("name"))
+71
View File
@@ -23,6 +23,8 @@ class Library(Base):
language: Mapped[str] = mapped_column(String, default="")
requests: Mapped[list["BookRequest"]] = relationship(back_populates="library")
watches: Mapped[list["WatchedSeries"]] = relationship(
back_populates="library", cascade="all, delete-orphan")
class BookRequest(Base):
@@ -48,6 +50,75 @@ class BookRequest(Base):
library: Mapped[Library] = relationship(back_populates="requests")
class History(Base):
"""One row per thing done to a request. Title and type are copied, and
there is no foreign key, so an entry outlives a deleted request."""
__tablename__ = "history"
id: Mapped[int] = mapped_column(primary_key=True)
at: Mapped[datetime.datetime] = mapped_column(
default=lambda: datetime.datetime.now(datetime.UTC)
)
# imported | appended | relocated | retagged | deleted | failed
event: Mapped[str] = mapped_column(String)
request_id: Mapped[int | None] = mapped_column(default=None)
title: Mapped[str] = mapped_column(String)
media_type: Mapped[str] = mapped_column(String)
source: Mapped[str] = mapped_column(String, default="")
dest: Mapped[str] = mapped_column(String, default="")
message: Mapped[str] = mapped_column(String, default="")
class WatchedSeries(Base):
"""A series looked up again now and then; new parts become suggestions."""
__tablename__ = "watched_series"
id: Mapped[int] = mapped_column(primary_key=True)
media_type: Mapped[str] = mapped_column(String)
library_id: Mapped[int] = mapped_column(ForeignKey("libraries.id"))
series_id: Mapped[str] = mapped_column(String)
source: Mapped[str] = mapped_column(String, default="")
title: Mapped[str] = mapped_column(String)
language: Mapped[str] = mapped_column(String, default="")
checked_at: Mapped[datetime.datetime | None] = mapped_column(default=None)
error: Mapped[str] = mapped_column(String, default="")
library: Mapped[Library] = relationship(back_populates="watches")
parts: Mapped[list["SeriesPart"]] = relationship(
back_populates="watch", cascade="all, delete-orphan")
class SeriesPart(Base):
"""Every part a watch has seen, so a later lookup can tell what is new."""
__tablename__ = "series_parts"
id: Mapped[int] = mapped_column(primary_key=True)
watch_id: Mapped[int] = mapped_column(ForeignKey("watched_series.id"))
key: Mapped[str] = mapped_column(String)
state: Mapped[str] = mapped_column(String) # known | new | requested | ignored
title: Mapped[str] = mapped_column(String)
authors: Mapped[str] = mapped_column(String, default="")
narrator: Mapped[str] = mapped_column(String, default="")
external_id: Mapped[str] = mapped_column(String, default="")
year: Mapped[int | None] = mapped_column(default=None)
series: Mapped[str] = mapped_column(String, default="")
volume: Mapped[int | None] = mapped_column(default=None)
cover_url: Mapped[str] = mapped_column(String, default="")
watch: Mapped[WatchedSeries] = relationship(back_populates="parts")
def record(session, event: str, req: BookRequest, **fields: str) -> History:
"""Add a history row; it is saved with the caller's next commit."""
row = History(event=event, request_id=req.id, title=req.title,
media_type=req.media_type, **fields)
session.add(row)
return row
_engine = None
SessionLocal = None
+14
View File
@@ -126,6 +126,18 @@ def relocate(request, library) -> str:
_remove_if_empty(src.parent, root)
return str(dest)
# one rename keeps the folder's inode, so Audiobookshelf sees a move and keeps
# the item; filling a new folder file by file makes it add a new item halfway
# and cache whatever cover it saw at that moment
moved_from = None
if not folder.exists():
folder.parent.mkdir(parents=True, exist_ok=True)
try:
src.rename(folder)
moved_from, src = src, folder
except OSError:
pass # another filesystem, or a target inside the source: file by file
audio = sorted(
(f for f in src.iterdir()
if f.is_file() and f.suffix.lower() in config.AUDIOBOOK_EXTENSIONS),
@@ -173,6 +185,8 @@ def relocate(request, library) -> str:
shutil.move(str(extra), target)
if not same_folder:
_remove_if_empty(src, root)
if moved_from:
_remove_if_empty(moved_from.parent, root)
return str(folder)
+30
View File
@@ -0,0 +1,30 @@
"""Keep the latest log records in memory for the system page."""
import collections
import datetime
import logging
# shortcut: lost on restart, write to /config if older entries are ever needed
RECORDS: collections.deque[dict] = collections.deque(maxlen=200)
class _Buffer(logging.Handler):
def emit(self, record: logging.LogRecord) -> None:
RECORDS.append({
"at": datetime.datetime.fromtimestamp(record.created, datetime.UTC).isoformat(),
"level": record.levelname,
"logger": record.name,
"message": self.format(record),
})
def install() -> None:
"""A handler on root would silence the last-resort stderr output, so
basicConfig keeps the container log. uvicorn's loggers do not propagate,
its errors are caught on uvicorn.error itself."""
if any(isinstance(h, _Buffer) for h in logging.getLogger().handlers):
return
logging.basicConfig(format="%(levelname)s:%(name)s: %(message)s")
logging.getLogger("wordarr").setLevel(logging.INFO)
buffer = _Buffer(logging.INFO)
logging.getLogger().addHandler(buffer)
logging.getLogger("uvicorn.error").addHandler(buffer)
+16 -2
View File
@@ -1,17 +1,22 @@
import asyncio
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from . import db
from .api import fs, imports, libraries, requests, search
from . import db, logs, version
from .api import fs, history, imports, libraries, requests, search, series, system
logs.install()
@asynccontextmanager
async def lifespan(app: FastAPI):
db.init_db()
watcher = asyncio.create_task(series.run_daily())
yield
watcher.cancel()
app = FastAPI(title="wordarr", lifespan=lifespan)
@@ -21,6 +26,15 @@ app.include_router(search.router)
app.include_router(requests.router)
app.include_router(imports.router)
app.include_router(fs.router)
app.include_router(history.router)
app.include_router(system.router)
app.include_router(series.router)
@app.get("/api/version")
def read_version():
return {"version": version.VERSION, "build": version.BUILD,
"built_at": version.BUILT_AT}
class RevalidatingStatics(StaticFiles):
+39 -10
View File
@@ -58,31 +58,54 @@ async def ebook_search(query: str, language: str = "",
return _merge(groups, _ebook_key)
def _as_audiobook(hits: list[MetadataResult]) -> list[MetadataResult]:
return [r.model_copy(update={"media_type": "audiobook"}) for r in hits]
async def audiobook_search(query: str, language: str = "",
fallback: bool = True) -> list[MetadataResult]:
"""Audible first, MusicBrainz alongside it.
"""Audible first, MusicBrainz and Hardcover alongside it.
Audible answers almost any query with *something*, so "no good hit" cannot be
detected reliably - measuring it showed the fallback would never fire. Both
are therefore asked in parallel and the MusicBrainz hits are appended, marked
with their source. Callers that search automatically (the import dialog when
it opens) pass fallback=False to spare the rate limited service.
detected reliably - measuring it showed the fallback would never fire. All
are therefore asked in parallel and the extra hits are appended, marked with
their source. Hardcover is there for its series: Audible leaves most volumes
of a series untagged. Callers that search automatically (the import dialog
when it opens) pass fallback=False to spare the rate limited services.
"""
if not fallback:
return await audible.search(query, language)
hits, extra = await asyncio.gather(
hits, *extras = await asyncio.gather(
audible.search(query, language),
musicbrainz.search(query, language),
hardcover.search(query, language),
return_exceptions=True,
)
if isinstance(hits, BaseException):
raise hits
if isinstance(extra, BaseException): # a slow fallback must not break search
log.warning("musicbrainz lookup failed for %r: %s", query, extra)
return hits
seen = {(r.title.strip().lower(), r.volume) for r in hits}
return hits + [r for r in extra if (r.title.strip().lower(), r.volume) not in seen]
for module, extra in zip((musicbrainz, hardcover), extras):
if isinstance(extra, BaseException): # a slow fallback must not break search
log.warning("%s lookup failed for %r: %s", module.__name__, query, extra)
continue
for r in _as_audiobook(extra):
if (key := (r.title.strip().lower(), r.volume)) not in seen:
seen.add(key)
hits.append(r)
return hits
async def _audiobook_series(series_id: str, title: str, language: str = "",
source: str = ""):
if source == "hardcover":
return _as_audiobook(await hardcover.series_books(series_id, language))
return await audible.series_episodes(series_id, title)
async def _ebook_series(series_id: str, title: str = "", language: str = "",
source: str = ""):
return await hardcover.series_books(series_id, language)
PROVIDERS = {
@@ -90,3 +113,9 @@ PROVIDERS = {
"audiobook": audiobook_search,
"comic": anilist.search,
}
# a media type without an entry gets no "whole series" button
SERIES_PROVIDERS = {
"audiobook": _audiobook_series,
"ebook": _ebook_series,
}
+190 -16
View File
@@ -8,10 +8,12 @@ The GraphQL search is Typesense backed and returns its hits as untyped JSON, so
every field is read defensively. A book row describes the work, not one edition,
so when a language is requested the matching editions are fetched in a second
query and their title/ISBN/cover replace the ones of the original edition.
Series are one query - unlike Audible, Hardcover knows them.
"""
import logging
import os
from collections import Counter
import httpx
@@ -21,6 +23,7 @@ ENDPOINT = "https://api.hardcover.app/v1/graphql"
TOKEN = os.environ.get("WORDARR_HARDCOVER_TOKEN", "").strip()
LANGUAGES = {"german": "de", "english": "en"}
RESULT_LIMIT = 10
SERIES_LIMIT = 500 # books per series
log = logging.getLogger(__name__)
@@ -35,7 +38,10 @@ query Search($q: String!, $per: Int!) {
_EDITIONS = """
query Editions($ids: [Int!]!, $lang: String!) {
editions(
where: {book_id: {_in: $ids}, language: {code2: {_eq: $lang}}}
where: {
book_id: {_in: $ids}
_or: [{language: {code2: {_eq: $lang}}}, {language_id: {_is_null: true}}]
}
order_by: {users_count: desc_nulls_last}
) {
book_id
@@ -44,6 +50,33 @@ query Editions($ids: [Int!]!, $lang: String!) {
isbn_10
release_date
cached_image
language { code2 }
}
other: editions(
where: {book_id: {_in: $ids}, language: {code2: {_neq: $lang}}}
distinct_on: book_id
) {
book_id
}
}
"""
_SERIES = """
query Series($id: Int!, $limit: Int!) {
series(where: {id: {_eq: $id}}) {
name
book_series(order_by: {position: asc_nulls_last}, limit: $limit) {
position
book {
id
title
release_year
users_count
cached_image
contributions(limit: 3) { author { name } }
}
}
}
}
"""
@@ -88,8 +121,27 @@ def _isbn(*candidates) -> str:
return ""
def _series_of(doc: dict) -> tuple[str, str]:
"""Name and id of the featured series; series_names[0] can be another one."""
featured = ((doc.get("featured_series") or {}).get("series")) or {}
if featured.get("name"):
return featured["name"], str(featured.get("id") or "")
names = doc.get("series_names") or []
ids = doc.get("series_ids") or []
return (names[0] if names else ""), (str(ids[0]) if ids else "")
def _volume(position) -> int | None:
# position 0 means "somewhere in this series", not "episode zero"
try:
number = int(float(position))
except (TypeError, ValueError):
return None
return number or None
def _to_result(doc: dict, language: str) -> MetadataResult:
series = doc.get("series_names") or []
series, series_id = _series_of(doc)
year = doc.get("release_year")
return MetadataResult(
media_type="ebook",
@@ -97,33 +149,60 @@ def _to_result(doc: dict, language: str) -> MetadataResult:
title=doc.get("title", ""),
authors=_authors(doc),
external_id=_isbn(doc.get("isbns")),
year=int(year) if str(year).isdigit() else None,
year=int(str(year)) if str(year).isdigit() else None,
language=language,
series=series[0] if series else "",
series=series,
series_id=series_id,
volume=_volume(doc.get("featured_series_position")),
cover_url=_image_url(doc.get("image")),
)
async def _localize(client: httpx.AsyncClient, results: dict[int, MetadataResult],
language: str) -> list[MetadataResult]:
"""Replace each hit with its edition in the wanted language, drop the rest."""
data = await _post(client, _EDITIONS,
{"ids": list(results), "lang": LANGUAGES[language]})
localized: dict[int, MetadataResult] = {}
async def _localized(client: httpx.AsyncClient, results: dict[int, MetadataResult],
language: str) -> dict[int, MetadataResult]:
"""The hits that have an edition in the wanted language, swapped in.
An edition without a language counts too, unless the book has one in another
language: translations are often a book row of their own whose editions nobody
tagged ("Tote Augen"), while an English row always has tagged editions.
"""
code = LANGUAGES[language]
data = await _post(client, _EDITIONS, {"ids": list(results), "lang": code})
foreign = {ed.get("book_id") for ed in data.get("other") or []}
known: dict[int, MetadataResult] = {}
unknown: dict[int, MetadataResult] = {}
for ed in data.get("editions") or []:
book_id = ed.get("book_id")
base = results.get(book_id)
if base is None or book_id in localized:
if base is None:
continue
if (ed.get("language") or {}).get("code2") == code:
target, tag = known, language
elif book_id not in foreign:
target, tag = unknown, ""
else:
continue
if book_id in target:
continue # the first edition is the most read one
release = str(ed.get("release_date") or "")
localized[book_id] = base.model_copy(update={
target[book_id] = base.model_copy(update={
"title": ed.get("title") or base.title,
"external_id": _isbn(ed.get("isbn_13"), ed.get("isbn_10")) or base.external_id,
"external_id": (_isbn(ed.get("isbn_13"), ed.get("isbn_10"))
or base.external_id),
"year": int(release[:4]) if release[:4].isdigit() else base.year,
"language": language,
"language": tag,
"cover_url": _image_url(ed.get("cached_image")) or base.cover_url,
})
return [localized[book_id] for book_id in results if book_id in localized]
return {**unknown, **known}
async def _localize(client: httpx.AsyncClient, results: dict[int, MetadataResult],
language: str) -> list[MetadataResult]:
"""Replace each hit with its edition in the wanted language, drop the rest.
Hits whose language is only unknown go last."""
localized = await _localized(client, results, language)
hits = [localized[book_id] for book_id in results if book_id in localized]
return sorted(hits, key=lambda r: not r.language)
async def search(query: str, language: str = "",
@@ -139,7 +218,7 @@ async def search(query: str, language: str = "",
doc = hit.get("document") or {}
book_id = doc.get("id")
try:
book_id = int(book_id)
book_id = int(str(book_id))
except (TypeError, ValueError):
continue
if doc.get("title"):
@@ -152,3 +231,98 @@ async def search(query: str, language: str = "",
# without the edition lookup the hits are still worth showing
log.warning("hardcover edition lookup failed for %r: %s", query, exc)
return list(results.values())
def _count(value) -> int:
try:
return int(str(value))
except (TypeError, ValueError):
return 0
def _author_set(book: dict) -> set[str]:
return {name.strip().lower()
for name in _authors(book).split(", ") if name.strip()}
def _main_author(authors: dict[int, set[str]]) -> str:
"""The name behind the series, or "" when the rows do not agree on one.
Translators and publishers ride along in the contributions, so the author of
the series is simply the name most rows share - and a row without it is the
foreign book that someone filed under this position by mistake.
"""
tally = Counter(name for names in authors.values() for name in names)
if not tally:
return ""
name, shared = tally.most_common(1)[0]
return name if shared * 2 > len(authors) else ""
async def series_books(series_id: str, language: str = "") -> list[MetadataResult]:
"""Every volume of a series, by position. A position often holds several book
rows - other editions of the story, and now and then a foreign book someone
filed here by mistake. The slot goes to the row that is by the author of the
series, has an edition in the wanted language and is the most read of those.
Unnumbered books go last."""
if not TOKEN:
return []
try:
numeric_id = int(str(series_id))
except (TypeError, ValueError):
return []
async with httpx.AsyncClient(timeout=30) as client:
data = await _post(client, _SERIES, {"id": numeric_id, "limit": SERIES_LIMIT})
row = (data.get("series") or [{}])[0]
name = row.get("name", "")
slots: dict[int, list[int]] = {} # volume -> book rows filed under it
loose: list[int] = []
books: dict[int, MetadataResult] = {}
authors: dict[int, set[str]] = {}
readers: dict[int, int] = {}
for entry in row.get("book_series") or []:
book = entry.get("book") or {}
try:
book_id = int(str(book.get("id")))
except (TypeError, ValueError):
continue
if not book.get("title") or book_id in books:
continue
volume = _volume(entry.get("position"))
books[book_id] = _to_result(
{**book, "image": book.get("cached_image"),
"series_names": [name], "series_ids": [numeric_id],
"featured_series_position": volume},
"",
)
authors[book_id] = _author_set(book)
readers[book_id] = _count(book.get("users_count"))
if volume is None:
loose.append(book_id)
else:
slots.setdefault(volume, []).append(book_id)
localized: dict[int, MetadataResult] = {}
if language in LANGUAGES and books:
try:
localized = await _localized(client, books, language)
except (httpx.HTTPError, RuntimeError) as exc:
log.warning("hardcover series editions failed for %s: %s",
series_id, exc)
author = _main_author(authors)
def rank(book_id: int) -> tuple:
# an untagged edition is no evidence: bundles and Spanish rows have them too
edition = localized.get(book_id)
return (author in authors[book_id] if author else True,
bool(edition and edition.language), readers[book_id])
def pick(candidates: list[int]) -> MetadataResult:
best = max(candidates, key=rank)
return localized.get(best) or books[best]
return ([pick(slots[volume]) for volume in sorted(slots)]
+ [localized.get(i) or books[i] for i in loose])
+24
View File
@@ -0,0 +1,24 @@
"""Version and build stamp, so the UI can say what is actually deployed."""
import os
from datetime import datetime, timezone
from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as package_version
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
def _built_at() -> str:
"""Newest source file - a redeploy that did not take keeps the old stamp."""
files = [*(ROOT / "wordarr").rglob("*.py"), *(ROOT / "static").glob("*")]
newest = max((f.stat().st_mtime for f in files), default=0.0)
return datetime.fromtimestamp(newest, timezone.utc).strftime("%Y-%m-%d %H:%M")
try:
VERSION = package_version("wordarr")
except PackageNotFoundError: # running from a checkout without an install
VERSION = "0.0.0+src"
BUILD = os.environ.get("WORDARR_BUILD", "").strip() # git sha, set by the image build
BUILT_AT = _built_at()