Author SHA1 Message Date
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
21 changed files with 1731 additions and 398 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 COPY static ./static
RUN pip install --no-cache-dir . RUN pip install --no-cache-dir .
ARG WORDARR_BUILD=""
ENV WORDARR_CONFIG_DIR=/config \ ENV WORDARR_CONFIG_DIR=/config \
WORDARR_DOWNLOAD_DIR=/mnt/downloads WORDARR_DOWNLOAD_DIR=/mnt/downloads \
WORDARR_BUILD=$WORDARR_BUILD
VOLUME /config VOLUME /config
EXPOSE 8787 EXPOSE 8787
+22 -1
View File
@@ -25,6 +25,26 @@ 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_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_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_GOOGLE_BOOKS_KEY` | – | optional, only needed when the keyless Google Books quota runs dry |
| `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 ## Usage
@@ -34,7 +54,8 @@ the same mount, otherwise moving turns into copy + delete.
2. **Search** – look up a title (ebooks: Hardcover, Google Books and Open 2. **Search** – look up a title (ebooks: Hardcover, Google Books and Open
Library, audiobooks: Audible plus MusicBrainz, manga: AniList) and request 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, 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, 3. **Import** – scan the download folder. wordarr suggests file→request matches,
which you confirm or correct; entries can be merged, split, or turned into 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 requests from their folder names. Every row names its format and size, and
+5 -1
View File
@@ -1,6 +1,10 @@
services: services:
wordarr: 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 container_name: wordarr
ports: ports:
- "8787:8787" - "8787:8787"
+394 -179
View File
File diff suppressed because it is too large. Load diff
+492
View File
@@ -0,0 +1,492 @@
// 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.langToggle": "Switch to English",
"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",
"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.langToggle": "Auf Deutsch umschalten",
"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",
"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();
}
+141 -176
View File
@@ -11,15 +11,17 @@
</head> </head>
<body> <body>
<header> <header>
<h1>📚 wordarr</h1> <h1>📚 wordarr <span id="app-version"></span></h1>
<nav> <nav>
<button data-view="search" class="active">Suche</button> <button data-view="search" class="active" data-i18n="nav.search"></button>
<button data-view="missing"> <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>
<button data-view="imported">Importiert</button> <button data-view="imported" data-i18n="nav.imported"></button>
<button data-view="import">Import</button> <button data-view="import" data-i18n="nav.import"></button>
<button data-view="settings">Libraries</button> <button data-view="settings" data-i18n="nav.settings"></button>
<button id="lang-toggle" class="secondary" data-i18n-title="nav.langToggle"></button>
</nav> </nav>
</header> </header>
<main> <main>
@@ -30,71 +32,67 @@
<option value="audiobook">Audiobook</option> <option value="audiobook">Audiobook</option>
<option value="comic">Comic/Manga</option> <option value="comic">Comic/Manga</option>
</select> </select>
<select id="search-language" title="Sprache der Ausgabe — filtert Ebook- und Audiobook-Suche"> <select id="search-language" data-i18n-title="search.languageTitle">
<option value="">Alle Sprachen</option> <option value="" data-i18n="search.allLanguages"></option>
<option value="german">Deutsch</option> <option value="german" data-i18n="lang.german"></option>
<option value="english">Englisch</option> <option value="english" data-i18n="lang.english"></option>
</select> </select>
<input id="search-q" placeholder="Titel, Autor oder ISBN…" required /> <input id="search-q" data-i18n-placeholder="search.placeholder" required />
<button type="submit" id="search-btn">Suchen</button> <button type="submit" id="search-btn" data-i18n="common.search"></button>
<button type="button" id="manual-btn" class="secondary"> <button type="button" id="manual-btn" class="secondary" data-i18n="search.manual"></button>
Manuell anlegen
</button>
</form> </form>
<div id="search-results" class="cards"></div> <div id="search-results" class="cards"></div>
<dialog id="manual-dialog"> <dialog id="manual-dialog">
<form id="manual-form" method="dialog"> <form id="manual-form" method="dialog">
<h3>Manuell anfragen</h3> <h3 data-i18n="manual.heading"></h3>
<label <label
>Typ ><span data-i18n="common.type"></span>
<select name="media_type"> <select name="media_type">
<option value="ebook">Ebook</option> <option value="ebook">Ebook</option>
<option value="audiobook">Audiobook</option> <option value="audiobook">Audiobook</option>
<option value="comic">Comic/Manga</option> <option value="comic">Comic/Manga</option>
</select> </select>
</label> </label>
<label>Titel <input name="title" required /></label> <label><span data-i18n="common.title"></span> <input name="title" required /></label>
<label>Autor(en) <input name="authors" /></label> <label><span data-i18n="common.authors"></span> <input name="authors" /></label>
<label>Sprecher <input name="narrator" /></label> <label><span data-i18n="common.narrator"></span> <input name="narrator" /></label>
<label>Serie <input name="series" /></label> <label><span data-i18n="common.series"></span> <input name="series" /></label>
<label>Band <input name="volume" type="number" min="0" /></label> <label><span data-i18n="common.volume"></span> <input name="volume" type="number" min="0" /></label>
<label <label
>bis Band (Bulk, optional) ><span data-i18n="manual.volumeTo"></span>
<input name="volume_to" type="number" min="0" /> <input name="volume_to" type="number" min="0" />
<span class="muted" <span class="muted" data-i18n="manual.bulkHint"></span>
>Legt pro Band/Folge eine Anfrage an, z.B. 1 bis 300</span
>
</label> </label>
<label>Jahr <input name="year" type="number" /></label> <label><span data-i18n="common.year"></span> <input name="year" type="number" /></label>
<label>ISBN/ID <input name="external_id" /></label> <label><span data-i18n="manual.externalId"></span> <input name="external_id" /></label>
<label <label
>Library ><span data-i18n="common.library"></span>
<select name="library_id" id="manual-library"></select <select name="library_id" id="manual-library"></select
></label> ></label>
<div class="row"> <div class="row">
<button value="cancel" class="secondary">Abbrechen</button> <button value="cancel" class="secondary" data-i18n="common.cancel"></button>
<button value="ok" id="manual-submit">Anfragen</button> <button value="ok" id="manual-submit" data-i18n="common.request"></button>
</div> </div>
</form> </form>
</dialog> </dialog>
<dialog id="series-dialog"> <dialog id="series-dialog">
<h3 id="series-title">Serie anfragen</h3> <h3 id="series-title"></h3>
<p class="muted" id="series-info"></p> <p class="muted" id="series-info"></p>
<div class="bar wrap"> <div class="bar wrap">
<label>von Folge <input id="series-from" type="number" min="0" /></label> <label><span data-i18n="series.from"></span> <input id="series-from" type="number" min="0" /></label>
<label>bis Folge <input id="series-to" 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">Alle</button> <button type="button" id="series-all" class="secondary" data-i18n="common.all"></button>
<button type="button" id="series-none" class="secondary">Keine</button> <button type="button" id="series-none" class="secondary" data-i18n="common.none"></button>
</div> </div>
<div id="series-list" class="series-list"></div> <div id="series-list" class="series-list"></div>
<div class="row"> <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>
<div class="row"> <div class="row">
<button type="button" id="series-cancel" class="secondary">Abbrechen</button> <button type="button" id="series-cancel" class="secondary" data-i18n="common.cancel"></button>
<button type="button" id="series-submit">Anfragen</button> <button type="button" id="series-submit" data-i18n="common.request"></button>
</div> </div>
</dialog> </dialog>
</section> </section>
@@ -102,23 +100,21 @@
<section id="view-missing" hidden> <section id="view-missing" hidden>
<div class="bar wrap"> <div class="bar wrap">
<select id="missing-filter-type"> <select id="missing-filter-type">
<option value="">Alle Typen</option> <option value="" data-i18n="list.allTypes"></option>
<option value="ebook">Ebook</option> <option value="ebook">Ebook</option>
<option value="audiobook">Audiobook</option> <option value="audiobook">Audiobook</option>
<option value="comic">Comic/Manga</option> <option value="comic">Comic/Manga</option>
</select> </select>
<select id="missing-filter-library"> <select id="missing-filter-library">
<option value="">Alle Libraries</option> <option value="" data-i18n="list.allLibraries"></option>
</select> </select>
<input id="missing-search" placeholder="Filtern (Titel/Autor/Serie)…" /> <input id="missing-search" data-i18n-placeholder="list.filterPlaceholder" />
<button id="missing-select-all" class="secondary">Alle auswählen</button> <button id="missing-select-all" class="secondary" data-i18n="common.selectAll"></button>
<button id="missing-bulk-delete" class="danger" hidden> <button id="missing-bulk-delete" class="danger" hidden data-i18n="common.removeSelected"></button>
Ausgewählte entfernen <div class="view-toggle" role="group" data-i18n-aria="list.view">
</button> <button data-viewmode="list" data-i18n-title="list.viewList">☰</button>
<div class="view-toggle" role="group" aria-label="Ansicht"> <button data-viewmode="small" data-i18n-title="list.viewSmall">▦</button>
<button data-viewmode="list" title="Liste">☰</button> <button data-viewmode="large" data-i18n-title="list.viewLarge">⊞</button>
<button data-viewmode="small" title="Kleine Symbole">▦</button>
<button data-viewmode="large" title="Große Symbole">⊞</button>
</div> </div>
</div> </div>
<div id="missing-list" class="cards"></div> <div id="missing-list" class="cards"></div>
@@ -126,15 +122,13 @@
<section id="view-imported" hidden> <section id="view-imported" hidden>
<div class="bar wrap"> <div class="bar wrap">
<input id="imported-search" placeholder="Filtern (Titel/Autor/Serie)…" /> <input id="imported-search" data-i18n-placeholder="list.filterPlaceholder" />
<button id="imported-select-all" class="secondary">Alle auswählen</button> <button id="imported-select-all" class="secondary" data-i18n="common.selectAll"></button>
<button id="imported-bulk-delete" class="danger" hidden> <button id="imported-bulk-delete" class="danger" hidden data-i18n="common.removeSelected"></button>
Ausgewählte entfernen <div class="view-toggle" role="group" data-i18n-aria="list.view">
</button> <button data-viewmode="list" data-i18n-title="list.viewList">☰</button>
<div class="view-toggle" role="group" aria-label="Ansicht"> <button data-viewmode="small" data-i18n-title="list.viewSmall">▦</button>
<button data-viewmode="list" title="Liste">☰</button> <button data-viewmode="large" data-i18n-title="list.viewLarge">⊞</button>
<button data-viewmode="small" title="Kleine Symbole">▦</button>
<button data-viewmode="large" title="Große Symbole">⊞</button>
</div> </div>
</div> </div>
<div id="imported-list" class="cards"></div> <div id="imported-list" class="cards"></div>
@@ -142,73 +136,61 @@
<section id="view-import" hidden> <section id="view-import" hidden>
<div class="bar wrap"> <div class="bar wrap">
<button id="scan-btn">Download-Ordner scannen</button> <button id="scan-btn" data-i18n="import.scan"></button>
<label class="muted" <label class="muted"
><input type="checkbox" id="split-dirs" /> Ordner als Einzeldateien ><input type="checkbox" id="split-dirs" /> <span data-i18n="import.splitDirs"></span
behandeln (z.B. Folgen-Sammlungen)</label ></label>
>
<span id="scan-info" class="muted"></span> <span id="scan-info" class="muted"></span>
</div> </div>
<div class="bar wrap" id="import-toolbar" hidden> <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"> <select id="import-filter-mode">
<option value="all">Alle</option> <option value="all" data-i18n="common.all"></option>
<option value="suggested">Nur mit Vorschlag</option> <option value="suggested" data-i18n="import.modeSuggested"></option>
<option value="unassigned">Nur ohne Zuordnung</option> <option value="unassigned" data-i18n="import.modeUnassigned"></option>
<option value="conflict">Nur Konflikte</option> <option value="conflict" data-i18n="import.modeConflict"></option>
</select> </select>
<select id="import-filter-format" title="Nur Einträge mit diesem Dateiformat zeigen"> <select id="import-filter-format" data-i18n-title="import.formatTitle">
<option value="">Alle Formate</option> <option value="" data-i18n="import.allFormats"></option>
</select> </select>
<select id="import-sort"> <select id="import-sort">
<option value="none">Reihenfolge: Scan</option> <option value="none" data-i18n="import.sortScan"></option>
<option value="score">Score absteigend</option> <option value="score" data-i18n="import.sortScore"></option>
<option value="name">Name A–Z</option> <option value="name" data-i18n="import.sortName"></option>
</select> </select>
<button id="import-select-suggested" class="secondary"> <button id="import-select-suggested" class="secondary" data-i18n="import.selectSuggested"></button>
Alle mit Vorschlag auswählen
</button>
<button id="import-select-all" class="secondary" <button id="import-select-all" class="secondary"
title="Alle Einträge der aktuellen Ansicht auswählen, auch ohne Zuordnung"> data-i18n-title="import.selectAllTitle" data-i18n="common.selectAll"></button>
Alle auswählen
</button>
<button id="import-select-page" class="secondary" <button id="import-select-page" class="secondary"
title="Nur die Einträge dieser Seite auswählen, alle anderen abwählen"> data-i18n-title="import.selectPageTitle" data-i18n="import.selectPage"></button>
Diese Seite auswählen <button id="import-deselect-all" class="secondary" data-i18n="import.deselectAll"></button>
</button> <button id="import-merge" class="secondary"
<button id="import-deselect-all" class="secondary"> data-i18n-title="import.mergeTitle" data-i18n="import.merge"></button>
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>
<button id="import-from-names" class="secondary" <button id="import-from-names" class="secondary"
title="Für ausgewählte Einträge ohne Zuordnung Anfragen aus den Ordnernamen anlegen"> data-i18n-title="import.fromNamesTitle" data-i18n="import.fromNames"></button>
Anfragen aus Ordnernamen
</button>
</div> </div>
<div class="table-scroll"> <div class="table-scroll">
<table id="import-table" hidden> <table id="import-table" hidden>
<thead> <thead>
<tr> <tr>
<th></th> <th></th>
<th>Datei/Ordner</th> <th data-i18n="import.colFile"></th>
<th>Typ</th> <th data-i18n="common.type"></th>
<th>Zuordnung</th> <th data-i18n="import.colAssignment"></th>
<th>Library</th> <th data-i18n="common.library"></th>
<th>Score</th> <th id="import-score-head" data-i18n="common.score"></th>
</tr> </tr>
</thead> </thead>
<tbody></tbody> <tbody></tbody>
</table> </table>
</div> </div>
<div class="bar" id="import-pager" hidden> <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> <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>
<div class="bar"> <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> <div id="import-progress" hidden>
<progress max="1" value="0"></progress> <progress max="1" value="0"></progress>
<span class="muted"></span> <span class="muted"></span>
@@ -218,49 +200,47 @@
<div id="import-results"></div> <div id="import-results"></div>
<dialog id="names-dialog"> <dialog id="names-dialog">
<h3>Anfragen aus Ordnernamen</h3> <h3 data-i18n="names.heading"></h3>
<p class="muted" id="names-intro"></p> <p class="muted" id="names-intro"></p>
<div class="bar wrap"> <div class="bar wrap">
<label>Serie <input id="names-series" placeholder="optional, für alle" /></label> <label><span data-i18n="common.series"></span>
<label>Autor <input id="names-authors" placeholder="optional, für alle" /></label> <input id="names-series" data-i18n-placeholder="common.optionalForAll" /></label>
<label>Library <select id="names-library"></select></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>
<div id="names-preview" class="series-list"></div> <div id="names-preview" class="series-list"></div>
<div class="row"> <div class="row">
<button type="button" id="names-cancel" class="secondary">Abbrechen</button> <button type="button" id="names-cancel" class="secondary" data-i18n="common.cancel"></button>
<button type="button" id="names-submit">Anlegen &amp; verbinden</button> <button type="button" id="names-submit" data-i18n="common.createAndLink"></button>
</div> </div>
</dialog> </dialog>
<dialog id="conflict-dialog"> <dialog id="conflict-dialog">
<h3>Mehrfach zugeordnete Anfragen</h3> <h3 data-i18n="conflict.heading"></h3>
<p class="muted" id="conflict-intro"></p> <p class="muted" id="conflict-intro"></p>
<div id="conflict-list" class="series-list"></div> <div id="conflict-list" class="series-list"></div>
<div class="row"> <div class="row">
<button type="button" id="conflict-cancel" class="secondary">Abbrechen</button> <button type="button" id="conflict-cancel" class="secondary" data-i18n="common.cancel"></button>
<button type="button" id="conflict-merge" class="secondary"> <button type="button" id="conflict-merge" class="secondary" data-i18n="conflict.merge"></button>
Als je ein Hörbuch zusammenfassen <button type="button" id="conflict-skip" data-i18n="conflict.skip"></button>
</button>
<button type="button" id="conflict-skip">
Konflikte überspringen, Rest importieren
</button>
</div> </div>
</dialog> </dialog>
<dialog id="quick-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 mono" id="quick-file"></p>
<p class="muted" id="quick-episode"></p> <p class="muted" id="quick-episode"></p>
<form id="quick-search-form" class="bar"> <form id="quick-search-form" class="bar">
<input id="quick-q" placeholder="Titel/Serie suchen…" required /> <input id="quick-q" data-i18n-placeholder="quick.placeholder" required />
<button type="submit">Suchen</button> <button type="submit" data-i18n="common.search"></button>
</form> </form>
<label class="muted" <label class="muted"
>Ziel-Library <select id="quick-library"></select ><span data-i18n="quick.targetLibrary"></span> <select id="quick-library"></select
></label> ></label>
<label class="muted" <label class="muted"
>Offene Anfragen ><span data-i18n="quick.openRequests"></span>
<input id="quick-req-filter" placeholder="nach Titel, Autor, Serie filtern…" /> <input id="quick-req-filter" data-i18n-placeholder="quick.reqFilter" />
</label> </label>
<div id="quick-scroll"> <div id="quick-scroll">
<div id="quick-open-requests"></div> <div id="quick-open-requests"></div>
@@ -268,49 +248,48 @@
</div> </div>
<div id="quick-foot"> <div id="quick-foot">
<details id="quick-manual"> <details id="quick-manual">
<summary id="quick-manual-summary"> <summary id="quick-manual-summary"></summary>
Nicht bei Audible? Manuell anlegen
</summary>
<p class="muted" id="quick-based-on" hidden></p> <p class="muted" id="quick-based-on" hidden></p>
<div class="quick-manual-fields"> <div class="quick-manual-fields">
<label>Titel <input id="quick-m-title" /></label> <label><span data-i18n="common.title"></span> <input id="quick-m-title" /></label>
<label>Autor <input id="quick-m-authors" placeholder="optional" /></label> <label><span data-i18n="common.author"></span>
<label>Serie <input id="quick-m-series" placeholder="optional" /></label> <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" <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> </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> </details>
<div class="row"> <div class="row">
<button type="button" id="quick-close" class="secondary"> <button type="button" id="quick-close" class="secondary" data-i18n="common.close"></button>
Schließen
</button>
</div> </div>
</div> </div>
</dialog> </dialog>
</section> </section>
<section id="view-settings" hidden> <section id="view-settings" hidden>
<h3>Libraries</h3> <h3 data-i18n="lib.heading"></h3>
<div class="table-scroll"> <div class="table-scroll">
<table id="lib-table"> <table id="lib-table">
<thead> <thead>
<tr> <tr>
<th>Name</th> <th data-i18n="common.name"></th>
<th>Typ</th> <th data-i18n="common.type"></th>
<th>Pfad</th> <th data-i18n="common.path"></th>
<th>Ordner-Schema</th> <th data-i18n="lib.folderTemplate"></th>
<th>Datei-Schema</th> <th data-i18n="lib.fileTemplate"></th>
<th>Sprache</th> <th data-i18n="common.language"></th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
<tbody></tbody> <tbody></tbody>
</table> </table>
</div> </div>
<h4 id="lib-form-title">Neue Library</h4> <h4 id="lib-form-title"></h4>
<form id="lib-form" class="bar wrap"> <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"> <select name="media_type">
<option value="ebook">Ebook</option> <option value="ebook">Ebook</option>
<option value="audiobook">Audiobook</option> <option value="audiobook">Audiobook</option>
@@ -324,29 +303,17 @@
list="path-suggestions" list="path-suggestions"
autocomplete="off" /> autocomplete="off" />
<datalist id="path-suggestions"></datalist> <datalist id="path-suggestions"></datalist>
<input <input name="folder_template" data-i18n-placeholder="lib.folderPlaceholder" class="wide" />
name="folder_template" <input name="file_template" data-i18n-placeholder="lib.filePlaceholder" class="wide" />
placeholder="Ordner-Schema (optional)" <select name="language" data-i18n-title="lib.languageTitle">
class="wide" /> <option value="" data-i18n="lib.anyLanguage"></option>
<input <option value="german" data-i18n="lang.german"></option>
name="file_template" <option value="english" data-i18n="lang.english"></option>
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>
</select> </select>
<button type="submit" id="lib-form-submit">Anlegen</button> <button type="submit" id="lib-form-submit" data-i18n="common.create"></button>
<button type="button" id="lib-form-cancel" class="secondary" hidden> <button type="button" id="lib-form-cancel" class="secondary" hidden data-i18n="common.cancel"></button>
Abbrechen
</button>
</form> </form>
<p class="muted"> <p class="muted" data-i18n="lib.placeholders"></p>
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>
</section> </section>
</main> </main>
@@ -360,32 +327,30 @@
<p class="muted" id="detail-status"></p> <p class="muted" id="detail-status"></p>
</div> </div>
</div> </div>
<label>Titel <input name="title" required /></label> <label><span data-i18n="common.title"></span> <input name="title" required /></label>
<label>Autor(en) <input name="authors" /></label> <label><span data-i18n="common.authors"></span> <input name="authors" /></label>
<label>Sprecher <input name="narrator" /></label> <label><span data-i18n="common.narrator"></span> <input name="narrator" /></label>
<label>Serie <input name="series" /></label> <label><span data-i18n="common.series"></span> <input name="series" /></label>
<div class="row"> <div class="row">
<label>Band <input name="volume" type="number" min="0" /></label> <label><span data-i18n="common.volume"></span> <input name="volume" type="number" min="0" /></label>
<label>Jahr <input name="year" type="number" /></label> <label><span data-i18n="common.year"></span> <input name="year" type="number" /></label>
</div> </div>
<label>ISBN/ASIN <input name="external_id" /></label> <label><span data-i18n="detail.externalId"></span> <input name="external_id" /></label>
<label>Library <select name="library_id" id="detail-library"></select></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> <p class="muted mono" id="detail-path"></p>
<div class="row"> <div class="row">
<button value="cancel" class="secondary">Abbrechen</button> <button value="cancel" class="secondary" data-i18n="common.cancel"></button>
<button type="button" id="detail-retag" class="secondary" hidden> <button type="button" id="detail-retag" class="secondary" hidden data-i18n="detail.retag"></button>
Speichern &amp; neu taggen
</button>
<button type="button" id="detail-relocate" class="secondary" hidden <button type="button" id="detail-relocate" class="secondary" hidden
title="Ordner und Dateinamen nach dem Schema der Library neu anlegen"> data-i18n-title="detail.relocateTitle" data-i18n="detail.relocate"></button>
Speichern &amp; neu ablegen <button value="ok" id="detail-save" data-i18n="common.save"></button>
</button>
<button value="ok" id="detail-save">Speichern</button>
</div> </div>
</form> </form>
</dialog> </dialog>
<div id="toast" hidden></div> <div id="toast" hidden></div>
<script src="/i18n.js"></script>
<script src="/app.js"></script> <script src="/app.js"></script>
</body> </body>
</html> </html>
+2
View File
@@ -10,6 +10,7 @@ header {
padding: 0.8rem; background: var(--panel); border-bottom: 1px solid var(--border); padding: 0.8rem; background: var(--panel); border-bottom: 1px solid var(--border);
} }
h1 { font-size: 1.2rem; } 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 { 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 { 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); } 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 { cursor: pointer; transition: border-color 0.1s, background 0.1s; }
.card.clickable:hover { border-color: var(--accent); background: #262b35; } .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; } .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; } .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; } .card-body { display: flex; flex-direction: column; gap: 0.25rem; min-width: 0; }
+167
View File
@@ -25,6 +25,7 @@ GERMAN_EDITION = {
"isbn_10": None, "isbn_10": None,
"release_date": "1957-03-01", "release_date": "1957-03-01",
"cached_image": "https://example.invalid/hobbit-de.jpg", "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")) == [] 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): def test_google_books_restricts_the_language(monkeypatch):
seen = [] 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)) monkeypatch.setattr(openlibrary, "search", lambda q, lang="", fb=True: record("ol", q, lang))
asyncio.run(metadata.ebook_search("hobbit", "german", fallback=False)) asyncio.run(metadata.ebook_search("hobbit", "german", fallback=False))
assert called == ["gb"] # no token, so Google Books stands in for Hardcover 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"]
+50
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 # a cover dropped next to the audio must survive the move
(Path(dest) / "cover.jpg").write_bytes(b"") (Path(dest) / "cover.jpg").write_bytes(b"")
inode = Path(dest).stat().st_ino
client.put(f"/api/requests/{req_id}", json={ client.put(f"/api/requests/{req_id}", json={
"library_id": lib_id, "title": "Philosopher's Stone", "library_id": lib_id, "title": "Philosopher's Stone",
"series": "Harry Potter", "volume": 1, "series": "Harry Potter", "volume": 1,
@@ -761,6 +763,8 @@ def test_relocate_after_fixing_the_series(client):
assert res["ok"] and res["moved"] assert res["ok"] and res["moved"]
new = root / "Harry Potter" / "Philosopher's Stone" new = root / "Harry Potter" / "Philosopher's Stone"
assert Path(res["dest"]) == new 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()) == [ assert sorted(p.name for p in new.iterdir()) == [
"Philosopher's Stone - Part 01.mp3", "Philosopher's Stone - Part 02.mp3", "cover.jpg", "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) "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): def test_relocate_is_a_no_op_when_nothing_changed(client):
root = client.tmp_path / "library" / "l" root = client.tmp_path / "library" / "l"
lib_id = client.post("/api/libraries", json={ lib_id = client.post("/api/libraries", json={
@@ -1135,3 +1178,10 @@ def test_extra_ebook_extensions_are_configurable(client, monkeypatch):
finally: finally:
monkeypatch.delenv("WORDARR_EXTRA_EBOOK_EXTENSIONS") monkeypatch.delenv("WORDARR_EXTRA_EBOOK_EXTENSIONS")
importlib.reload(config_module) 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] == "-"
+39
View File
@@ -82,3 +82,42 @@ def test_a_broken_fallback_never_breaks_the_search(monkeypatch):
monkeypatch.setattr(metadata.musicbrainz, "search", boom) monkeypatch.setattr(metadata.musicbrainz, "search", boom)
results = asyncio.run(metadata.audiobook_search("Toteninsel")) results = asyncio.run(metadata.audiobook_search("Toteninsel"))
assert [r.title for r in results] == ["Da"] 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")]
+60 -1
View File
@@ -57,12 +57,14 @@ def server(tmp_path_factory):
@pytest.fixture @pytest.fixture
def page(server): def page(server):
"""A German browser, so the UI language is the same everywhere."""
with sync_api.sync_playwright() as pw: with sync_api.sync_playwright() as pw:
try: try:
browser = pw.chromium.launch() browser = pw.chromium.launch()
except Exception as exc: # browsers not downloaded except Exception as exc: # browsers not downloaded
pytest.skip(f"no chromium: {exc}") 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 = [] errors = []
page.on("pageerror", lambda e: errors.append(str(e))) page.on("pageerror", lambda e: errors.append(str(e)))
page.goto(server, wait_until="networkidle") page.goto(server, wait_until="networkidle")
@@ -124,3 +126,60 @@ def test_format_tags_and_the_format_filter(page):
rows = page.locator("#import-table tbody tr") rows = page.locator("#import-table tbody tr")
assert rows.count() == 1 assert rows.count() == 1
assert "epub" in rows.first.locator(".tag.fmt").text_content() 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()
+7
View File
@@ -47,6 +47,10 @@ class ImportItem(BaseModel):
request_id: int request_id: int
# add to an already imported audiobook instead of importing a new one # add to an already imported audiobook instead of importing a new one
append: bool = False 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): class ImportIn(BaseModel):
@@ -101,9 +105,12 @@ def do_import(data: ImportIn, session: Session = Depends(get_session)):
" — mehrere Einträge auf dieselbe Anfrage? Dann vorher zusammenfassen." " — mehrere Einträge auf dieselbe Anfrage? Dann vorher zusammenfassen."
)}) )})
continue continue
if item.series.strip() and item.volume is not None:
req.series, req.volume = item.series.strip(), item.volume
try: try:
dest = mover.import_item(item.path, item.files, item.is_dir, req, req.library) dest = mover.import_item(item.path, item.files, item.is_dir, req, req.library)
except Exception as exc: except Exception as exc:
session.rollback() # the next commit must not keep the folder's series
results.append({"path": shown, "ok": False, "error": str(exc)}) results.append({"path": shown, "ok": False, "error": str(exc)})
continue continue
req.status = "imported" req.status = "imported"
+2 -3
View File
@@ -1,5 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel, ConfigDict
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -21,8 +21,7 @@ class LibraryIn(BaseModel):
class LibraryOut(LibraryIn): class LibraryOut(LibraryIn):
id: int id: int
class Config: model_config = ConfigDict(from_attributes=True)
from_attributes = True
def _validate(data: LibraryIn): def _validate(data: LibraryIn):
+2 -3
View File
@@ -1,7 +1,7 @@
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel, ConfigDict
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -34,8 +34,7 @@ class RequestOut(RequestIn):
imported_path: str imported_path: str
library_name: str = "" library_name: str = ""
class Config: model_config = ConfigDict(from_attributes=True)
from_attributes = True
def _out(r: BookRequest) -> RequestOut: def _out(r: BookRequest) -> RequestOut:
+10 -6
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from ..metadata import PROVIDERS, audible from ..metadata import PROVIDERS, SERIES_PROVIDERS
from ..metadata.base import MetadataResult from ..metadata.base import MetadataResult
router = APIRouter(prefix="/api/search", tags=["search"]) 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]) @router.get("/series", response_model=list[MetadataResult])
async def series(series_id: str, title: str): async def series(series_id: str, title: str = "", media_type: str = "audiobook",
"""All episodes of an Audible series, sorted by episode number. Audible has language: str = "", source: str = ""):
no series endpoint, so this is a paged keyword crawl and may miss a few """All parts of a series, by number. Audible has no series endpoint, so
episodes - those can still be added by hand.""" 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: if not series_id:
raise HTTPException(400, "series_id required") raise HTTPException(400, "series_id required")
try: try:
return await audible.series_episodes(series_id, title) return await lookup(series_id, title, language, source)
except Exception as exc: except Exception as exc:
raise HTTPException(502, f"metadata provider error: {exc}") raise HTTPException(502, f"metadata provider error: {exc}")
+14
View File
@@ -126,6 +126,18 @@ def relocate(request, library) -> str:
_remove_if_empty(src.parent, root) _remove_if_empty(src.parent, root)
return str(dest) 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( audio = sorted(
(f for f in src.iterdir() (f for f in src.iterdir()
if f.is_file() and f.suffix.lower() in config.AUDIOBOOK_EXTENSIONS), 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) shutil.move(str(extra), target)
if not same_folder: if not same_folder:
_remove_if_empty(src, root) _remove_if_empty(src, root)
if moved_from:
_remove_if_empty(moved_from.parent, root)
return str(folder) return str(folder)
+7 -1
View File
@@ -4,7 +4,7 @@ from pathlib import Path
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from . import db from . import db, version
from .api import fs, imports, libraries, requests, search from .api import fs, imports, libraries, requests, search
@@ -23,6 +23,12 @@ app.include_router(imports.router)
app.include_router(fs.router) app.include_router(fs.router)
@app.get("/api/version")
def read_version():
return {"version": version.VERSION, "build": version.BUILD,
"built_at": version.BUILT_AT}
class RevalidatingStatics(StaticFiles): class RevalidatingStatics(StaticFiles):
"""Without a Cache-Control header the browser caches heuristically and does """Without a Cache-Control header the browser caches heuristically and does
not ask again, so a redeployed app.js kept rendering the old UI. no-cache not ask again, so a redeployed app.js kept rendering the old UI. no-cache
+39 -10
View File
@@ -58,31 +58,54 @@ async def ebook_search(query: str, language: str = "",
return _merge(groups, _ebook_key) 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 = "", async def audiobook_search(query: str, language: str = "",
fallback: bool = True) -> list[MetadataResult]: 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 Audible answers almost any query with *something*, so "no good hit" cannot be
detected reliably - measuring it showed the fallback would never fire. Both detected reliably - measuring it showed the fallback would never fire. All
are therefore asked in parallel and the MusicBrainz hits are appended, marked are therefore asked in parallel and the extra hits are appended, marked with
with their source. Callers that search automatically (the import dialog when their source. Hardcover is there for its series: Audible leaves most volumes
it opens) pass fallback=False to spare the rate limited service. 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: if not fallback:
return await audible.search(query, language) return await audible.search(query, language)
hits, extra = await asyncio.gather( hits, *extras = await asyncio.gather(
audible.search(query, language), audible.search(query, language),
musicbrainz.search(query, language), musicbrainz.search(query, language),
hardcover.search(query, language),
return_exceptions=True, return_exceptions=True,
) )
if isinstance(hits, BaseException): if isinstance(hits, BaseException):
raise hits 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} 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 = { PROVIDERS = {
@@ -90,3 +113,9 @@ PROVIDERS = {
"audiobook": audiobook_search, "audiobook": audiobook_search,
"comic": anilist.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, 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 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. 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 logging
import os import os
from collections import Counter
import httpx import httpx
@@ -21,6 +23,7 @@ ENDPOINT = "https://api.hardcover.app/v1/graphql"
TOKEN = os.environ.get("WORDARR_HARDCOVER_TOKEN", "").strip() TOKEN = os.environ.get("WORDARR_HARDCOVER_TOKEN", "").strip()
LANGUAGES = {"german": "de", "english": "en"} LANGUAGES = {"german": "de", "english": "en"}
RESULT_LIMIT = 10 RESULT_LIMIT = 10
SERIES_LIMIT = 500 # books per series
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -35,7 +38,10 @@ query Search($q: String!, $per: Int!) {
_EDITIONS = """ _EDITIONS = """
query Editions($ids: [Int!]!, $lang: String!) { query Editions($ids: [Int!]!, $lang: String!) {
editions( 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} order_by: {users_count: desc_nulls_last}
) { ) {
book_id book_id
@@ -44,6 +50,33 @@ query Editions($ids: [Int!]!, $lang: String!) {
isbn_10 isbn_10
release_date release_date
cached_image 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 "" 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: 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") year = doc.get("release_year")
return MetadataResult( return MetadataResult(
media_type="ebook", media_type="ebook",
@@ -97,33 +149,60 @@ def _to_result(doc: dict, language: str) -> MetadataResult:
title=doc.get("title", ""), title=doc.get("title", ""),
authors=_authors(doc), authors=_authors(doc),
external_id=_isbn(doc.get("isbns")), 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, 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")), cover_url=_image_url(doc.get("image")),
) )
async def _localize(client: httpx.AsyncClient, results: dict[int, MetadataResult], async def _localized(client: httpx.AsyncClient, results: dict[int, MetadataResult],
language: str) -> list[MetadataResult]: language: str) -> dict[int, MetadataResult]:
"""Replace each hit with its edition in the wanted language, drop the rest.""" """The hits that have an edition in the wanted language, swapped in.
data = await _post(client, _EDITIONS,
{"ids": list(results), "lang": LANGUAGES[language]}) An edition without a language counts too, unless the book has one in another
localized: dict[int, MetadataResult] = {} 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 []: for ed in data.get("editions") or []:
book_id = ed.get("book_id") book_id = ed.get("book_id")
base = results.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 continue # the first edition is the most read one
release = str(ed.get("release_date") or "") 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, "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, "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, "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 = "", async def search(query: str, language: str = "",
@@ -139,7 +218,7 @@ async def search(query: str, language: str = "",
doc = hit.get("document") or {} doc = hit.get("document") or {}
book_id = doc.get("id") book_id = doc.get("id")
try: try:
book_id = int(book_id) book_id = int(str(book_id))
except (TypeError, ValueError): except (TypeError, ValueError):
continue continue
if doc.get("title"): 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 # without the edition lookup the hits are still worth showing
log.warning("hardcover edition lookup failed for %r: %s", query, exc) log.warning("hardcover edition lookup failed for %r: %s", query, exc)
return list(results.values()) 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()