Compare commits
38
Commits
v0.1.0
...
40af29ab15
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40af29ab15 | ||
|
|
377d2c8557 | ||
|
|
33d82ab7d4 | ||
|
|
173e24a362 | ||
|
|
98c7214140 | ||
|
|
be1da8bbf3 | ||
|
|
1487512183 | ||
|
|
55d7c8bdda | ||
|
|
0f48981478 | ||
|
|
175dface50 | ||
|
|
4441c5a24d | ||
|
|
ebd981cc3c | ||
|
|
b85bdcf5aa | ||
|
|
0d2ada3901 | ||
|
|
d0b12d5d09 | ||
|
|
46c3c91a8d | ||
|
|
44bf65b3a5 | ||
|
|
0f56d13577 | ||
|
|
4deaca4f1c | ||
|
|
025fedc556 | ||
|
|
46b88e2fd5 | ||
|
|
07c988c443 | ||
|
|
f7f89a3cc7 | ||
|
|
40a829c80c | ||
|
|
0c504790df | ||
|
|
53927f7e11 | ||
|
|
adc708ae29 | ||
|
|
5f4b3cb564 | ||
|
|
b51d5754e0 | ||
|
|
91ad7ff98b | ||
|
|
c8d4d239ec | ||
|
|
13e2a0693e | ||
|
|
dc83071a0a | ||
|
|
e22eded2bf | ||
|
|
6493d15c52 | ||
|
|
8e7be708b2 | ||
|
|
d9b4c95ca6 | ||
|
|
169e84901c |
No files matched your search
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -25,6 +25,29 @@ the same mount, otherwise moving turns into copy + delete.
|
|||||||
| `WORDARR_EXTRA_EBOOK_EXTENSIONS` | – | extra ebook formats the scan should offer, e.g. `.kepub,.lit,.rtf` |
|
| `WORDARR_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_ABS_URL` | – | Audiobookshelf base URL, e.g. `http://audiobookshelf`; after an import, relocate or retag it scans the libraries whose folders hold the files, so both containers must mount them under the same path |
|
||||||
|
| `WORDARR_ABS_TOKEN` | – | API token of an Audiobookshelf admin (only admins may start a scan) |
|
||||||
|
| `WORDARR_WEBHOOK_URL` | – | gets every history row as JSON (`event`, `request_id`, `title`, `media_type`, `source`, `dest`, `message`) |
|
||||||
|
| `WORDARR_BUILD` | – | shown next to the version, e.g. the git sha; also a build arg of the image |
|
||||||
|
|
||||||
|
The header shows the running version and, behind it, the build stamp (the newest
|
||||||
|
source file, or `WORDARR_BUILD` when the image was built with it) - the quickest
|
||||||
|
way to see whether a redeploy actually arrived. `GET /api/version` returns the
|
||||||
|
same three values. Build with the git sha via
|
||||||
|
`WORDARR_BUILD=$(git rev-parse --short HEAD) docker compose up -d --build`.
|
||||||
|
|
||||||
|
The UI speaks German and English; the button next to the navigation switches
|
||||||
|
between them and remembers the choice. Without one, the browser language decides.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
`.gitea/workflows/build.yml` runs the tests on every push to `main` and then
|
||||||
|
builds and pushes `gitea.steppencloud.de/admin/wordarr` as `:latest` and
|
||||||
|
`:<short sha>`, passing that sha as `WORDARR_BUILD`. It logs in with
|
||||||
|
`REGISTRY_TOKEN` if the secret exists, otherwise with the runner's own
|
||||||
|
`GITEA_TOKEN`. The last step pulls the fresh image on the host and recreates
|
||||||
|
just the wordarr container of the arr stack, so the version in the header is the
|
||||||
|
commit that was pushed.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@@ -34,7 +57,8 @@ the same mount, otherwise moving turns into copy + delete.
|
|||||||
2. **Search** – look up a title (ebooks: Hardcover, Google Books and Open
|
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
@@ -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"
|
||||||
|
|||||||
+728
-192
File diff suppressed because it is too large.
Load diff
+594
@@ -0,0 +1,594 @@
|
|||||||
|
// UI strings for both languages. Keys are grouped by the view they belong to;
|
||||||
|
// {placeholders} are filled by t().
|
||||||
|
const STRINGS = {
|
||||||
|
de: {
|
||||||
|
"common.cancel": "Abbrechen",
|
||||||
|
"common.save": "Speichern",
|
||||||
|
"common.create": "Anlegen",
|
||||||
|
"common.request": "Anfragen",
|
||||||
|
"common.search": "Suchen",
|
||||||
|
"common.all": "Alle",
|
||||||
|
"common.none": "Keine",
|
||||||
|
"common.close": "Schließen",
|
||||||
|
"common.edit": "Bearbeiten",
|
||||||
|
"common.delete": "Löschen",
|
||||||
|
"common.link": "Verbinden",
|
||||||
|
"common.createAndLink": "Anlegen & verbinden",
|
||||||
|
"common.selectAll": "Alle auswählen",
|
||||||
|
"common.removeSelected": "Ausgewählte entfernen",
|
||||||
|
"common.removeSelectedN": "Ausgewählte entfernen ({n})",
|
||||||
|
"common.library": "Library",
|
||||||
|
"common.type": "Typ",
|
||||||
|
"common.title": "Titel",
|
||||||
|
"common.authors": "Autor(en)",
|
||||||
|
"common.author": "Autor",
|
||||||
|
"common.narrator": "Sprecher",
|
||||||
|
"common.series": "Serie",
|
||||||
|
"common.volume": "Band",
|
||||||
|
"common.year": "Jahr",
|
||||||
|
"common.name": "Name",
|
||||||
|
"common.path": "Pfad",
|
||||||
|
"common.language": "Sprache",
|
||||||
|
"common.score": "Score",
|
||||||
|
"common.status": "Status",
|
||||||
|
"common.noLibrary": "— keine Library —",
|
||||||
|
"common.nothingHere": "Nichts hier.",
|
||||||
|
"common.loading": "Wird geladen…",
|
||||||
|
"common.optionalForAll": "optional, für alle",
|
||||||
|
"common.optional": "optional",
|
||||||
|
"common.needLibraryFirst": "Erst eine Library für diesen Typ anlegen (Tab Libraries)",
|
||||||
|
"common.creating": "Lege an…",
|
||||||
|
"lang.german": "Deutsch",
|
||||||
|
"lang.english": "Englisch",
|
||||||
|
|
||||||
|
"nav.search": "Suche",
|
||||||
|
"nav.missing": "Missing",
|
||||||
|
"nav.imported": "Importiert",
|
||||||
|
"nav.import": "Import",
|
||||||
|
"nav.settings": "Libraries",
|
||||||
|
"nav.activity": "Aktivität",
|
||||||
|
"nav.series": "Serien",
|
||||||
|
"watch.button": "Beobachten",
|
||||||
|
"watch.buttonTitle": "Täglich nach neuen Teilen dieser Serie schauen",
|
||||||
|
"watch.added": "Serie wird beobachtet",
|
||||||
|
"watch.already": "Diese Serie wird schon beobachtet",
|
||||||
|
"watch.check": "Jetzt prüfen",
|
||||||
|
"watch.empty": "Noch keine Serie beobachtet. In der Suche unter „Ganze Serie…“ auf „Beobachten“ klicken.",
|
||||||
|
"watch.checked": "geprüft {at}",
|
||||||
|
"watch.error": "Letzte Prüfung fehlgeschlagen: {error}",
|
||||||
|
"watch.nothingNew": "Nichts Neues.",
|
||||||
|
"watch.ignore": "Ignorieren",
|
||||||
|
"watch.remove": "Nicht mehr beobachten",
|
||||||
|
"watch.confirmRemove": "Diese Serie nicht mehr beobachten?",
|
||||||
|
"nav.system": "System",
|
||||||
|
"system.health": "Zustand",
|
||||||
|
"system.healthy": "Alles in Ordnung.",
|
||||||
|
"system.version": "Version",
|
||||||
|
"system.logs": "Letzte Log-Einträge",
|
||||||
|
"system.level": "Stufe",
|
||||||
|
"system.source": "Quelle",
|
||||||
|
"system.message": "Meldung",
|
||||||
|
"health.download.missing": "Der Download-Ordner {path} existiert nicht.",
|
||||||
|
"health.download.readonly": "wordarr kann im Download-Ordner {path} nicht schreiben, Importe schlagen fehl.",
|
||||||
|
"health.library.missing": "Library „{name}“: Der Ordner {path} existiert nicht.",
|
||||||
|
"health.library.readonly": "Library „{name}“: wordarr kann in {path} nicht schreiben.",
|
||||||
|
"health.hardcover.token": "Kein Hardcover-Token gesetzt (WORDARR_HARDCOVER_TOKEN), die Ebook-Suche fällt auf Google Books und Open Library zurück.",
|
||||||
|
"health.abs.incomplete": "Audiobookshelf ist nur halb eingerichtet: WORDARR_ABS_URL und WORDARR_ABS_TOKEN müssen beide gesetzt sein.",
|
||||||
|
"nav.langToggle": "Switch to English",
|
||||||
|
"history.time": "Zeit",
|
||||||
|
"history.event": "Ereignis",
|
||||||
|
"history.imported": "importiert",
|
||||||
|
"history.appended": "ergänzt",
|
||||||
|
"history.relocated": "verschoben",
|
||||||
|
"history.retagged": "neu getaggt",
|
||||||
|
"history.deleted": "gelöscht",
|
||||||
|
"history.failed": "fehlgeschlagen",
|
||||||
|
"history.adopted": "übernommen",
|
||||||
|
"adopt.button": "Einlesen",
|
||||||
|
"adopt.buttonTitle": "Dateien, die schon in der Library liegen, offenen Anfragen zuordnen",
|
||||||
|
"adopt.heading": "„{name}“ einlesen",
|
||||||
|
"adopt.intro": "{n} Dateien passen zu offenen Anfragen dieser Library und bleiben, wo sie sind. {rest} weitere ohne Treffer.",
|
||||||
|
"adopt.submit": "Übernehmen",
|
||||||
|
"adopt.done": "{n} übernommen",
|
||||||
|
"adopt.partial": "{n} übernommen, Fehler: {failed}",
|
||||||
|
|
||||||
|
"search.languageTitle": "Sprache der Ausgabe — filtert Ebook- und Audiobook-Suche",
|
||||||
|
"search.allLanguages": "Alle Sprachen",
|
||||||
|
"search.placeholder": "Titel, Autor oder ISBN…",
|
||||||
|
"search.manual": "Manuell anlegen",
|
||||||
|
"search.running": "Suche…",
|
||||||
|
"search.wholeSeries": "Ganze Serie…",
|
||||||
|
"search.requested": "✓ Angefragt",
|
||||||
|
"search.emptyEbook": "Keine Treffer. Tipp: ISBN ohne Bindestriche oder Titel + Autor versuchen — und ggf. „Alle Sprachen“ wählen.",
|
||||||
|
"search.emptyAudiobook": "Keine Treffer. Tipp: EAN/ISBN funktioniert bei Audible nicht — nach Titel/Autor suchen.",
|
||||||
|
"search.emptyComic": "Keine Treffer bei AniList (Manga). Westliche Comics per „Manuell anlegen“ erfassen.",
|
||||||
|
|
||||||
|
"manual.heading": "Manuell anfragen",
|
||||||
|
"manual.volumeTo": "bis Band (Bulk, optional)",
|
||||||
|
"manual.bulkHint": "Legt pro Band/Folge eine Anfrage an, z.B. 1 bis 300",
|
||||||
|
"manual.externalId": "ISBN/ID",
|
||||||
|
"manual.noLibraryChosen": "Keine Library gewählt",
|
||||||
|
"manual.needStartVolume": "Für Bulk bitte Start-Band angeben",
|
||||||
|
"manual.created": "Anfrage angelegt",
|
||||||
|
"manual.createdN": "{n} Anfragen angelegt",
|
||||||
|
|
||||||
|
"series.fallbackTitle": "Serie",
|
||||||
|
"series.from": "von Nr.",
|
||||||
|
"series.to": "bis Nr.",
|
||||||
|
"series.loading": "{unit} werden gesucht…",
|
||||||
|
"series.unitVolumes": "Bände",
|
||||||
|
"series.unitEpisodes": "Folgen",
|
||||||
|
"series.found": "{n} {unit} gefunden",
|
||||||
|
"series.range": " (Nr. {from}–{to})",
|
||||||
|
"series.noteEbook": ". Bände ohne Ausgabe in der gewählten Sprache stehen mit ihrem Originaltitel da.",
|
||||||
|
"series.noteAudiobook": ". Audible hat keine Serien-Abfrage — einzelne Folgen können fehlen.",
|
||||||
|
"series.gapVolumes": "… {n} Bände nicht bei Hardcover gefunden ({from}–{to})",
|
||||||
|
"series.gapVolume": "… {n} Band nicht bei Hardcover gefunden ({from}–{to})",
|
||||||
|
"series.gapEpisodes": "… {n} Folgen nicht bei Audible gefunden ({from}–{to})",
|
||||||
|
"series.gapEpisode": "… {n} Folge nicht bei Audible gefunden ({from}–{to})",
|
||||||
|
"series.requestN": "{n} anfragen",
|
||||||
|
"series.needLibrary": "Erst eine {type}-Library anlegen (Tab Libraries)",
|
||||||
|
"series.createdN": "{n} Anfragen angelegt",
|
||||||
|
"series.skippedN": ", {n} bereits vorhanden",
|
||||||
|
|
||||||
|
"list.allTypes": "Alle Typen",
|
||||||
|
"list.allLibraries": "Alle Libraries",
|
||||||
|
"list.filterPlaceholder": "Filtern (Titel/Autor/Serie)…",
|
||||||
|
"list.view": "Ansicht",
|
||||||
|
"list.viewList": "Liste",
|
||||||
|
"list.viewSmall": "Kleine Symbole",
|
||||||
|
"list.viewLarge": "Große Symbole",
|
||||||
|
"list.confirmDeleteRequests": "{n} Anfrage(n) entfernen?",
|
||||||
|
"list.confirmDeleteImported": "{n} Eintrag/Einträge entfernen? Die importierten Dateien in der Library bleiben erhalten.",
|
||||||
|
"list.deletedN": "{n} entfernt",
|
||||||
|
"list.sort": "Sortierung",
|
||||||
|
"list.sortNewest": "Neueste zuerst",
|
||||||
|
"list.sortTitle": "Titel",
|
||||||
|
"list.sortAuthor": "Autor",
|
||||||
|
"list.sortSeries": "Serie und Band",
|
||||||
|
"list.moveTo": "Bibliothek ändern ({n})…",
|
||||||
|
"list.movedN": "{n} nach {library} verschoben",
|
||||||
|
|
||||||
|
"detail.statusMissing": "fehlt noch",
|
||||||
|
"detail.statusImported": "importiert",
|
||||||
|
"detail.externalId": "ISBN/ASIN",
|
||||||
|
"detail.retag": "Speichern & neu taggen",
|
||||||
|
"detail.relocate": "Speichern & neu ablegen",
|
||||||
|
"detail.relocateTitle": "Ordner und Dateinamen nach dem Schema der Library neu anlegen",
|
||||||
|
"detail.relocating": "Lege ab…",
|
||||||
|
"detail.relocated": "Neu abgelegt: {dest}",
|
||||||
|
"detail.alreadyPlaced": "Liegt bereits richtig",
|
||||||
|
"detail.retagging": "Tagge…",
|
||||||
|
"detail.retagged": "{n} Datei(en) neu getaggt",
|
||||||
|
"detail.saved": "Anfrage gespeichert",
|
||||||
|
"detail.duplicateConfirm": "Trotzdem anlegen?",
|
||||||
|
|
||||||
|
"import.scan": "Download-Ordner scannen",
|
||||||
|
"import.splitDirs": "Ordner als Einzeldateien behandeln (z.B. Folgen-Sammlungen)",
|
||||||
|
"import.scanning": "Scanne…",
|
||||||
|
"import.candidates": "{n} Kandidat(en) in {dir}",
|
||||||
|
"import.remaining": "{n} Kandidat(en) übrig — für den aktuellen Stand erneut scannen",
|
||||||
|
"import.filterPlaceholder": "Filtern (Name/Pfad)…",
|
||||||
|
"import.modeSuggested": "Nur mit Vorschlag",
|
||||||
|
"import.modeUnassigned": "Nur ohne Zuordnung",
|
||||||
|
"import.modeConflict": "Nur Konflikte",
|
||||||
|
"import.formatTitle": "Nur Einträge mit diesem Dateiformat zeigen",
|
||||||
|
"import.allFormats": "Alle Formate",
|
||||||
|
"import.sortScan": "Reihenfolge: Scan",
|
||||||
|
"import.sortScore": "Score absteigend",
|
||||||
|
"import.sortName": "Name A–Z",
|
||||||
|
"import.selectSuggested": "Alle mit Vorschlag auswählen",
|
||||||
|
"import.selectAllTitle": "Alle Einträge der aktuellen Ansicht auswählen, auch ohne Zuordnung",
|
||||||
|
"import.selectPage": "Diese Seite auswählen",
|
||||||
|
"import.selectPageTitle": "Nur die Einträge dieser Seite auswählen, alle anderen abwählen",
|
||||||
|
"import.deselectAll": "Alle abwählen",
|
||||||
|
"import.merge": "Ausgewählte zusammenfassen",
|
||||||
|
"import.mergeTitle": "Mehrteiler: ausgewählte Einträge als ein Hörbuch importieren",
|
||||||
|
"import.fromNames": "Anfragen aus Ordnernamen",
|
||||||
|
"import.fromNamesTitle": "Für ausgewählte Einträge ohne Zuordnung Anfragen aus den Ordnernamen anlegen",
|
||||||
|
"import.colFile": "Datei/Ordner",
|
||||||
|
"import.colAssignment": "Zuordnung",
|
||||||
|
"import.prev": "← Zurück",
|
||||||
|
"import.next": "Weiter →",
|
||||||
|
"import.run": "Ausgewählte importieren",
|
||||||
|
"import.skip": "— überspringen —",
|
||||||
|
"import.appendGroup": "Bereits importiert — Teile anhängen",
|
||||||
|
"import.quickTitle": "Metadaten suchen und Anfrage direkt verbinden",
|
||||||
|
"import.unmerge": "Zusammenfassung wieder auflösen",
|
||||||
|
"import.splitDir": "Ordner in einzelne Titel aufteilen",
|
||||||
|
"import.parts": "{parts} Teile · {files} Dateien",
|
||||||
|
"import.maybeSeparate": "{n} Titel?",
|
||||||
|
"import.folderSeries": "→ {series} #{volume}",
|
||||||
|
"import.folderSeriesTitle": "Reihe und Band aus dem Ordnernamen ersetzen beim Import die des Eintrags ({series} #{volume})",
|
||||||
|
"import.maybeSeparateTitle": "Die {n} Dateien tragen verschiedene Titel und sind je groß genug für ein ganzes Buch — mit ✂️ aufteilen",
|
||||||
|
"import.libraryCellTitle": "Ziel-Library dieser Anfrage ändern",
|
||||||
|
"import.conflict": "⚠ Konflikt",
|
||||||
|
"import.conflictTitle": "Mehrere Einträge zeigen auf dieselbe Anfrage",
|
||||||
|
"import.running": "läuft…",
|
||||||
|
"import.done": "✓ importiert",
|
||||||
|
"import.failed": "✗ Fehler",
|
||||||
|
"import.pageInfo": "Seite {page}/{pages} · {total} Einträge{filtered} · {selected} ausgewählt",
|
||||||
|
"import.filtered": " (gefiltert: {n})",
|
||||||
|
"import.movedToLibrary": "„{title}\" → {library}",
|
||||||
|
"import.selectedN": "{n} Einträge {what} ausgewählt",
|
||||||
|
"import.selectedWithout": ", davon {n} ohne Zuordnung",
|
||||||
|
"import.thisPage": "dieser Seite",
|
||||||
|
"import.inTotal": "insgesamt",
|
||||||
|
"import.nothingSelected": "Nichts ausgewählt",
|
||||||
|
"import.needTwo": "Mindestens zwei Einträge auswählen",
|
||||||
|
"import.sameTypeOnly": "Nur Einträge desselben Typs lassen sich zusammenfassen",
|
||||||
|
"import.differentFolders": "Die Einträge liegen in verschiedenen Ordnern",
|
||||||
|
"import.mergedN": "{n} Einträge als „{name}\" zusammengefasst ({files} Dateien)",
|
||||||
|
"import.splitN": "„{name}\" in {n} Einzeltitel aufgeteilt",
|
||||||
|
"import.nothingImported": "Nichts importiert: alle ausgewählten Einträge waren mehreren Anfragen zugeordnet — Filter „Nur Konflikte\" zeigt sie.",
|
||||||
|
"import.summary": "{n} importiert",
|
||||||
|
"import.summaryFailed": ", {n} Fehler",
|
||||||
|
"import.summarySkipped": "{n} Anfrage(n) waren mehreren Einträgen zugeordnet und wurden übersprungen — Filter „Nur Konflikte\" zeigt sie.",
|
||||||
|
"import.finished": "Import fertig",
|
||||||
|
"import.finishedFailed": "Import fertig, {n} Fehler",
|
||||||
|
"import.progressDone": "{total}/{total} · fertig",
|
||||||
|
|
||||||
|
"names.heading": "Anfragen aus Ordnernamen",
|
||||||
|
"names.intro": "{n} Einträge ohne Zuordnung ({type}). Titel und Folgennummer kommen aus dem Ordnernamen; Serie und Autor gelten für alle.",
|
||||||
|
"names.more": "… und {n} weitere",
|
||||||
|
"names.noneWithout": "Keine ausgewählten Einträge ohne Zuordnung",
|
||||||
|
"names.result": "{created} Anfragen angelegt{skipped}, {linked} Einträge verbunden",
|
||||||
|
"names.existed": ", {n} gab es schon",
|
||||||
|
|
||||||
|
"conflict.heading": "Mehrfach zugeordnete Anfragen",
|
||||||
|
"conflict.intro": "{n} Anfrage(n) sind mehreren Einträgen zugeordnet. Gehören die Einträge zusammen (Mehrteiler), lassen sie sich als ein Hörbuch importieren — sonst überspringst du sie, korrigierst die Zuordnung und importierst sie später.",
|
||||||
|
"conflict.merge": "Als je ein Hörbuch zusammenfassen",
|
||||||
|
"conflict.skip": "Konflikte überspringen, Rest importieren",
|
||||||
|
|
||||||
|
"quick.heading": "Suchen & Anfragen",
|
||||||
|
"quick.placeholder": "Titel/Serie suchen…",
|
||||||
|
"quick.targetLibrary": "Ziel-Library",
|
||||||
|
"quick.openRequests": "Offene Anfragen",
|
||||||
|
"quick.reqFilter": "nach Titel, Autor, Serie filtern…",
|
||||||
|
"quick.manualSummary": "Nicht bei Audible? Manuell anlegen",
|
||||||
|
"quick.editSummary": "Felder anpassen",
|
||||||
|
"quick.episodeVolume": "Folge/Band",
|
||||||
|
"quick.searching": "Suche läuft…",
|
||||||
|
"quick.episodeNote": "Folge {n} — nicht mitgesucht",
|
||||||
|
"quick.matchingOpen": "Passende offene Requests:",
|
||||||
|
"quick.hits": "{shown} von {total} Treffern ({open} offen)",
|
||||||
|
"quick.noHits": "Kein Treffer unter {open} offenen Anfragen",
|
||||||
|
"quick.linked": "Mit bestehendem Request verbunden",
|
||||||
|
"quick.editTitle": "Felder vor dem Anlegen anpassen (z.B. Serie)",
|
||||||
|
"quick.pickTitle": "Anfrage anlegen und mit diesem Eintrag verbinden",
|
||||||
|
"quick.basedOn": "Übernommen von Audible: „{title}\" — Sprecher, Cover, Jahr und ASIN bleiben erhalten.",
|
||||||
|
"quick.requestedAndLinked": "„{title}\" angefragt und verbunden",
|
||||||
|
"quick.needTitle": "Titel angeben",
|
||||||
|
|
||||||
|
"lib.heading": "Libraries",
|
||||||
|
"lib.folderTemplate": "Ordner-Schema",
|
||||||
|
"lib.fileTemplate": "Datei-Schema",
|
||||||
|
"lib.new": "Neue Library",
|
||||||
|
"lib.edit": "Library „{name}“ bearbeiten",
|
||||||
|
"lib.namePlaceholder": "Name (z.B. Kids)",
|
||||||
|
"lib.folderPlaceholder": "Ordner-Schema (optional)",
|
||||||
|
"lib.filePlaceholder": "Datei-Schema (optional)",
|
||||||
|
"lib.languageTitle": "Sprache der Ausgaben in dieser Library — filtert die Metadaten-Suche",
|
||||||
|
"lib.anyLanguage": "Sprache: egal",
|
||||||
|
"lib.placeholders": "Platzhalter: {Author} {Authors} {Narrator} {Narrators} {Title} {Year} {Series} {Volume} — {Author}/{Narrator} nennen den ersten Namen, {Authors}/{Narrators} alle. Leere Werte fallen samt Trenner weg.",
|
||||||
|
"lib.confirmDelete": "Library löschen?",
|
||||||
|
"lib.saved": "Library gespeichert — gilt für künftige Importe",
|
||||||
|
"lib.created": "Library angelegt",
|
||||||
|
},
|
||||||
|
|
||||||
|
en: {
|
||||||
|
"common.cancel": "Cancel",
|
||||||
|
"common.save": "Save",
|
||||||
|
"common.create": "Create",
|
||||||
|
"common.request": "Request",
|
||||||
|
"common.search": "Search",
|
||||||
|
"common.all": "All",
|
||||||
|
"common.none": "None",
|
||||||
|
"common.close": "Close",
|
||||||
|
"common.edit": "Edit",
|
||||||
|
"common.delete": "Delete",
|
||||||
|
"common.link": "Link",
|
||||||
|
"common.createAndLink": "Create & link",
|
||||||
|
"common.selectAll": "Select all",
|
||||||
|
"common.removeSelected": "Remove selected",
|
||||||
|
"common.removeSelectedN": "Remove selected ({n})",
|
||||||
|
"common.library": "Library",
|
||||||
|
"common.type": "Type",
|
||||||
|
"common.title": "Title",
|
||||||
|
"common.authors": "Author(s)",
|
||||||
|
"common.author": "Author",
|
||||||
|
"common.narrator": "Narrator",
|
||||||
|
"common.series": "Series",
|
||||||
|
"common.volume": "Volume",
|
||||||
|
"common.year": "Year",
|
||||||
|
"common.name": "Name",
|
||||||
|
"common.path": "Path",
|
||||||
|
"common.language": "Language",
|
||||||
|
"common.score": "Score",
|
||||||
|
"common.status": "Status",
|
||||||
|
"common.noLibrary": "— no library —",
|
||||||
|
"common.nothingHere": "Nothing here.",
|
||||||
|
"common.loading": "Loading…",
|
||||||
|
"common.optionalForAll": "optional, for all",
|
||||||
|
"common.optional": "optional",
|
||||||
|
"common.needLibraryFirst": "Create a library for this type first (Libraries tab)",
|
||||||
|
"common.creating": "Creating…",
|
||||||
|
"lang.german": "German",
|
||||||
|
"lang.english": "English",
|
||||||
|
|
||||||
|
"nav.search": "Search",
|
||||||
|
"nav.missing": "Missing",
|
||||||
|
"nav.imported": "Imported",
|
||||||
|
"nav.import": "Import",
|
||||||
|
"nav.settings": "Libraries",
|
||||||
|
"nav.activity": "Activity",
|
||||||
|
"nav.series": "Series",
|
||||||
|
"watch.button": "Watch",
|
||||||
|
"watch.buttonTitle": "Look for new parts of this series every day",
|
||||||
|
"watch.added": "Series is being watched",
|
||||||
|
"watch.already": "This series is already watched",
|
||||||
|
"watch.check": "Check now",
|
||||||
|
"watch.empty": "No series watched yet. In the search, open “Whole series…” and click “Watch”.",
|
||||||
|
"watch.checked": "checked {at}",
|
||||||
|
"watch.error": "Last check failed: {error}",
|
||||||
|
"watch.nothingNew": "Nothing new.",
|
||||||
|
"watch.ignore": "Ignore",
|
||||||
|
"watch.remove": "Stop watching",
|
||||||
|
"watch.confirmRemove": "Stop watching this series?",
|
||||||
|
"nav.system": "System",
|
||||||
|
"system.health": "Health",
|
||||||
|
"system.healthy": "All good.",
|
||||||
|
"system.version": "Version",
|
||||||
|
"system.logs": "Recent log entries",
|
||||||
|
"system.level": "Level",
|
||||||
|
"system.source": "Source",
|
||||||
|
"system.message": "Message",
|
||||||
|
"health.download.missing": "The download folder {path} does not exist.",
|
||||||
|
"health.download.readonly": "wordarr cannot write to the download folder {path}, imports will fail.",
|
||||||
|
"health.library.missing": "Library “{name}”: the folder {path} does not exist.",
|
||||||
|
"health.library.readonly": "Library “{name}”: wordarr cannot write to {path}.",
|
||||||
|
"health.hardcover.token": "No Hardcover token set (WORDARR_HARDCOVER_TOKEN), ebook search falls back to Google Books and Open Library.",
|
||||||
|
"health.abs.incomplete": "Audiobookshelf is only half set up: WORDARR_ABS_URL and WORDARR_ABS_TOKEN must both be set.",
|
||||||
|
"nav.langToggle": "Auf Deutsch umschalten",
|
||||||
|
"history.time": "Time",
|
||||||
|
"history.event": "Event",
|
||||||
|
"history.imported": "imported",
|
||||||
|
"history.appended": "appended",
|
||||||
|
"history.relocated": "relocated",
|
||||||
|
"history.retagged": "retagged",
|
||||||
|
"history.deleted": "deleted",
|
||||||
|
"history.failed": "failed",
|
||||||
|
"history.adopted": "adopted",
|
||||||
|
"adopt.button": "Scan",
|
||||||
|
"adopt.buttonTitle": "Link files already in the library to open requests",
|
||||||
|
"adopt.heading": "Scan “{name}”",
|
||||||
|
"adopt.intro": "{n} files match open requests of this library and stay where they are. {rest} more without a match.",
|
||||||
|
"adopt.submit": "Adopt",
|
||||||
|
"adopt.done": "{n} adopted",
|
||||||
|
"adopt.partial": "{n} adopted, errors: {failed}",
|
||||||
|
|
||||||
|
"search.languageTitle": "Edition language — filters the ebook and audiobook search",
|
||||||
|
"search.allLanguages": "All languages",
|
||||||
|
"search.placeholder": "Title, author or ISBN…",
|
||||||
|
"search.manual": "Add manually",
|
||||||
|
"search.running": "Searching…",
|
||||||
|
"search.wholeSeries": "Whole series…",
|
||||||
|
"search.requested": "✓ Requested",
|
||||||
|
"search.emptyEbook": "No hits. Try the ISBN without dashes, or title + author — and maybe pick “All languages”.",
|
||||||
|
"search.emptyAudiobook": "No hits. EAN/ISBN does not work on Audible — search by title/author.",
|
||||||
|
"search.emptyComic": "No hits on AniList (manga). Add western comics via “Add manually”.",
|
||||||
|
|
||||||
|
"manual.heading": "Request manually",
|
||||||
|
"manual.volumeTo": "to volume (bulk, optional)",
|
||||||
|
"manual.bulkHint": "Creates one request per volume/episode, e.g. 1 to 300",
|
||||||
|
"manual.externalId": "ISBN/ID",
|
||||||
|
"manual.noLibraryChosen": "No library chosen",
|
||||||
|
"manual.needStartVolume": "Bulk needs a starting volume",
|
||||||
|
"manual.created": "Request created",
|
||||||
|
"manual.createdN": "{n} requests created",
|
||||||
|
|
||||||
|
"series.fallbackTitle": "Series",
|
||||||
|
"series.from": "from no.",
|
||||||
|
"series.to": "to no.",
|
||||||
|
"series.loading": "Looking for {unit}…",
|
||||||
|
"series.unitVolumes": "volumes",
|
||||||
|
"series.unitEpisodes": "episodes",
|
||||||
|
"series.found": "{n} {unit} found",
|
||||||
|
"series.range": " (no. {from}–{to})",
|
||||||
|
"series.noteEbook": ". Volumes without an edition in the chosen language keep their original title.",
|
||||||
|
"series.noteAudiobook": ". Audible has no series query — single episodes can be missing.",
|
||||||
|
"series.gapVolumes": "… {n} volumes not found on Hardcover ({from}–{to})",
|
||||||
|
"series.gapVolume": "… {n} volume not found on Hardcover ({from}–{to})",
|
||||||
|
"series.gapEpisodes": "… {n} episodes not found on Audible ({from}–{to})",
|
||||||
|
"series.gapEpisode": "… {n} episode not found on Audible ({from}–{to})",
|
||||||
|
"series.requestN": "Request {n}",
|
||||||
|
"series.needLibrary": "Create an {type} library first (Libraries tab)",
|
||||||
|
"series.createdN": "{n} requests created",
|
||||||
|
"series.skippedN": ", {n} already there",
|
||||||
|
|
||||||
|
"list.allTypes": "All types",
|
||||||
|
"list.allLibraries": "All libraries",
|
||||||
|
"list.filterPlaceholder": "Filter (title/author/series)…",
|
||||||
|
"list.view": "View",
|
||||||
|
"list.viewList": "List",
|
||||||
|
"list.viewSmall": "Small icons",
|
||||||
|
"list.viewLarge": "Large icons",
|
||||||
|
"list.confirmDeleteRequests": "Remove {n} request(s)?",
|
||||||
|
"list.confirmDeleteImported": "Remove {n} entr(y/ies)? The imported files stay in the library.",
|
||||||
|
"list.deletedN": "{n} removed",
|
||||||
|
"list.sort": "Sort order",
|
||||||
|
"list.sortNewest": "Newest first",
|
||||||
|
"list.sortTitle": "Title",
|
||||||
|
"list.sortAuthor": "Author",
|
||||||
|
"list.sortSeries": "Series and volume",
|
||||||
|
"list.moveTo": "Change library ({n})…",
|
||||||
|
"list.movedN": "{n} moved to {library}",
|
||||||
|
|
||||||
|
"detail.statusMissing": "still missing",
|
||||||
|
"detail.statusImported": "imported",
|
||||||
|
"detail.externalId": "ISBN/ASIN",
|
||||||
|
"detail.retag": "Save & re-tag",
|
||||||
|
"detail.relocate": "Save & re-file",
|
||||||
|
"detail.relocateTitle": "Rebuild folder and file names from the library's scheme",
|
||||||
|
"detail.relocating": "Filing…",
|
||||||
|
"detail.relocated": "Re-filed: {dest}",
|
||||||
|
"detail.alreadyPlaced": "Already in the right place",
|
||||||
|
"detail.retagging": "Tagging…",
|
||||||
|
"detail.retagged": "{n} file(s) re-tagged",
|
||||||
|
"detail.saved": "Request saved",
|
||||||
|
"detail.duplicateConfirm": "Create anyway?",
|
||||||
|
|
||||||
|
"import.scan": "Scan download folder",
|
||||||
|
"import.splitDirs": "Treat folders as single files (e.g. episode collections)",
|
||||||
|
"import.scanning": "Scanning…",
|
||||||
|
"import.candidates": "{n} candidate(s) in {dir}",
|
||||||
|
"import.remaining": "{n} candidate(s) left — scan again for the current state",
|
||||||
|
"import.filterPlaceholder": "Filter (name/path)…",
|
||||||
|
"import.modeSuggested": "With suggestion only",
|
||||||
|
"import.modeUnassigned": "Unassigned only",
|
||||||
|
"import.modeConflict": "Conflicts only",
|
||||||
|
"import.formatTitle": "Show only entries with this file format",
|
||||||
|
"import.allFormats": "All formats",
|
||||||
|
"import.sortScan": "Order: scan",
|
||||||
|
"import.sortScore": "Score descending",
|
||||||
|
"import.sortName": "Name A–Z",
|
||||||
|
"import.selectSuggested": "Select all with a suggestion",
|
||||||
|
"import.selectAllTitle": "Select every entry of the current view, unassigned ones too",
|
||||||
|
"import.selectPage": "Select this page",
|
||||||
|
"import.selectPageTitle": "Select only this page's entries, deselect all others",
|
||||||
|
"import.deselectAll": "Deselect all",
|
||||||
|
"import.merge": "Merge selected",
|
||||||
|
"import.mergeTitle": "Multi-part title: import the selected entries as one audiobook",
|
||||||
|
"import.fromNames": "Requests from folder names",
|
||||||
|
"import.fromNamesTitle": "Create requests from the folder names of the selected unassigned entries",
|
||||||
|
"import.colFile": "File/folder",
|
||||||
|
"import.colAssignment": "Assignment",
|
||||||
|
"import.prev": "← Back",
|
||||||
|
"import.next": "Next →",
|
||||||
|
"import.run": "Import selected",
|
||||||
|
"import.skip": "— skip —",
|
||||||
|
"import.appendGroup": "Already imported — append parts",
|
||||||
|
"import.quickTitle": "Search metadata and link the request right away",
|
||||||
|
"import.unmerge": "Undo the merge",
|
||||||
|
"import.splitDir": "Split the folder into single titles",
|
||||||
|
"import.parts": "{parts} parts · {files} files",
|
||||||
|
"import.maybeSeparate": "{n} titles?",
|
||||||
|
"import.folderSeries": "→ {series} #{volume}",
|
||||||
|
"import.folderSeriesTitle": "The series and volume from the folder name replace the request's on import ({series} #{volume})",
|
||||||
|
"import.maybeSeparateTitle": "The {n} files carry different titles and are each big enough for a whole book — split with ✂️",
|
||||||
|
"import.libraryCellTitle": "Change this request's target library",
|
||||||
|
"import.conflict": "⚠ Conflict",
|
||||||
|
"import.conflictTitle": "Several entries point at the same request",
|
||||||
|
"import.running": "running…",
|
||||||
|
"import.done": "✓ imported",
|
||||||
|
"import.failed": "✗ Error",
|
||||||
|
"import.pageInfo": "Page {page}/{pages} · {total} entries{filtered} · {selected} selected",
|
||||||
|
"import.filtered": " (filtered: {n})",
|
||||||
|
"import.movedToLibrary": "“{title}” → {library}",
|
||||||
|
"import.selectedN": "{n} entries {what} selected",
|
||||||
|
"import.selectedWithout": ", {n} of them unassigned",
|
||||||
|
"import.thisPage": "on this page",
|
||||||
|
"import.inTotal": "in total",
|
||||||
|
"import.nothingSelected": "Nothing selected",
|
||||||
|
"import.needTwo": "Select at least two entries",
|
||||||
|
"import.sameTypeOnly": "Only entries of the same type can be merged",
|
||||||
|
"import.differentFolders": "The entries live in different folders",
|
||||||
|
"import.mergedN": "{n} entries merged as “{name}” ({files} files)",
|
||||||
|
"import.splitN": "“{name}” split into {n} single titles",
|
||||||
|
"import.nothingImported": "Nothing imported: every selected entry was assigned to several requests — the “Conflicts only” filter shows them.",
|
||||||
|
"import.summary": "{n} imported",
|
||||||
|
"import.summaryFailed": ", {n} failed",
|
||||||
|
"import.summarySkipped": "{n} request(s) were assigned to several entries and were skipped — the “Conflicts only” filter shows them.",
|
||||||
|
"import.finished": "Import finished",
|
||||||
|
"import.finishedFailed": "Import finished, {n} failed",
|
||||||
|
"import.progressDone": "{total}/{total} · done",
|
||||||
|
|
||||||
|
"names.heading": "Requests from folder names",
|
||||||
|
"names.intro": "{n} unassigned entries ({type}). Title and episode number come from the folder name; series and author apply to all of them.",
|
||||||
|
"names.more": "… and {n} more",
|
||||||
|
"names.noneWithout": "No selected entries without an assignment",
|
||||||
|
"names.result": "{created} requests created{skipped}, {linked} entries linked",
|
||||||
|
"names.existed": ", {n} already existed",
|
||||||
|
|
||||||
|
"conflict.heading": "Requests assigned more than once",
|
||||||
|
"conflict.intro": "{n} request(s) are assigned to several entries. If those entries belong together (a multi-part title), they can be imported as one audiobook — otherwise skip them, fix the assignment and import them later.",
|
||||||
|
"conflict.merge": "Merge each into one audiobook",
|
||||||
|
"conflict.skip": "Skip conflicts, import the rest",
|
||||||
|
|
||||||
|
"quick.heading": "Search & request",
|
||||||
|
"quick.placeholder": "Search title/series…",
|
||||||
|
"quick.targetLibrary": "Target library",
|
||||||
|
"quick.openRequests": "Open requests",
|
||||||
|
"quick.reqFilter": "filter by title, author, series…",
|
||||||
|
"quick.manualSummary": "Not on Audible? Add manually",
|
||||||
|
"quick.editSummary": "Adjust the fields",
|
||||||
|
"quick.episodeVolume": "Episode/volume",
|
||||||
|
"quick.searching": "Searching…",
|
||||||
|
"quick.episodeNote": "Episode {n} — not part of the search",
|
||||||
|
"quick.matchingOpen": "Matching open requests:",
|
||||||
|
"quick.hits": "{shown} of {total} hits ({open} open)",
|
||||||
|
"quick.noHits": "No hit among {open} open requests",
|
||||||
|
"quick.linked": "Linked to the existing request",
|
||||||
|
"quick.editTitle": "Adjust the fields before creating (e.g. the series)",
|
||||||
|
"quick.pickTitle": "Create the request and link it to this entry",
|
||||||
|
"quick.basedOn": "Taken from Audible: “{title}” — narrator, cover, year and ASIN are kept.",
|
||||||
|
"quick.requestedAndLinked": "“{title}” requested and linked",
|
||||||
|
"quick.needTitle": "Enter a title",
|
||||||
|
|
||||||
|
"lib.heading": "Libraries",
|
||||||
|
"lib.folderTemplate": "Folder scheme",
|
||||||
|
"lib.fileTemplate": "File scheme",
|
||||||
|
"lib.new": "New library",
|
||||||
|
"lib.edit": "Edit library “{name}”",
|
||||||
|
"lib.namePlaceholder": "Name (e.g. Kids)",
|
||||||
|
"lib.folderPlaceholder": "Folder scheme (optional)",
|
||||||
|
"lib.filePlaceholder": "File scheme (optional)",
|
||||||
|
"lib.languageTitle": "Language of this library's editions — filters the metadata search",
|
||||||
|
"lib.anyLanguage": "Language: any",
|
||||||
|
"lib.placeholders": "Placeholders: {Author} {Authors} {Narrator} {Narrators} {Title} {Year} {Series} {Volume} — {Author}/{Narrator} name the first one, {Authors}/{Narrators} all of them. Empty values drop out with their separator.",
|
||||||
|
"lib.confirmDelete": "Delete library?",
|
||||||
|
"lib.saved": "Library saved — applies to future imports",
|
||||||
|
"lib.created": "Library created",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const LANG_KEY = "wordarr.lang";
|
||||||
|
|
||||||
|
function initialLang() {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(LANG_KEY);
|
||||||
|
if (saved && STRINGS[saved]) return saved;
|
||||||
|
} catch {}
|
||||||
|
return (navigator.language || "en").startsWith("de") ? "de" : "en";
|
||||||
|
}
|
||||||
|
|
||||||
|
let lang = initialLang();
|
||||||
|
|
||||||
|
// {Placeholders} of the naming scheme must survive, so only known vars are replaced
|
||||||
|
function t(key, vars) {
|
||||||
|
let s = STRINGS[lang][key] ?? STRINGS.en[key] ?? key;
|
||||||
|
if (vars) {
|
||||||
|
for (const [name, value] of Object.entries(vars)) {
|
||||||
|
s = s.split("{" + name + "}").join(String(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
const I18N_ATTRS = { "i18nPlaceholder": "placeholder", "i18nTitle": "title", "i18nAria": "aria-label" };
|
||||||
|
|
||||||
|
function applyI18n(root = document) {
|
||||||
|
root.querySelectorAll("[data-i18n]").forEach((el) => (el.textContent = t(el.dataset.i18n)));
|
||||||
|
for (const [data, attr] of Object.entries(I18N_ATTRS)) {
|
||||||
|
root.querySelectorAll(`[data-${data.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase())}]`)
|
||||||
|
.forEach((el) => el.setAttribute(attr, t(el.dataset[data])));
|
||||||
|
}
|
||||||
|
document.documentElement.lang = lang;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLang(next) {
|
||||||
|
lang = next;
|
||||||
|
try { localStorage.setItem(LANG_KEY, next); } catch {}
|
||||||
|
applyI18n();
|
||||||
|
if (typeof onLangChange === "function") onLangChange();
|
||||||
|
}
|
||||||
+218
-176
@@ -11,15 +11,26 @@
|
|||||||
</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="series">
|
||||||
|
<span data-i18n="nav.series"></span>
|
||||||
|
<span id="watch-badge" class="badge" hidden></span>
|
||||||
|
</button>
|
||||||
|
<button data-view="activity" data-i18n="nav.activity"></button>
|
||||||
|
<button data-view="settings" data-i18n="nav.settings"></button>
|
||||||
|
<button data-view="system">
|
||||||
|
<span data-i18n="nav.system"></span>
|
||||||
|
<span id="health-badge" class="badge" hidden></span>
|
||||||
|
</button>
|
||||||
|
<button id="lang-toggle" class="secondary" data-i18n-title="nav.langToggle"></button>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
@@ -30,71 +41,68 @@
|
|||||||
<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-watch" class="secondary" data-i18n="watch.button" data-i18n-title="watch.buttonTitle"></button>
|
||||||
|
<button type="button" id="series-submit" data-i18n="common.request"></button>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
</section>
|
</section>
|
||||||
@@ -102,23 +110,28 @@
|
|||||||
<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>
|
<select id="missing-sort" data-i18n-title="list.sort">
|
||||||
<button id="missing-bulk-delete" class="danger" hidden>
|
<option value="newest" data-i18n="list.sortNewest"></option>
|
||||||
Ausgewählte entfernen
|
<option value="title" data-i18n="list.sortTitle"></option>
|
||||||
</button>
|
<option value="author" data-i18n="list.sortAuthor"></option>
|
||||||
<div class="view-toggle" role="group" aria-label="Ansicht">
|
<option value="series" data-i18n="list.sortSeries"></option>
|
||||||
<button data-viewmode="list" title="Liste">☰</button>
|
</select>
|
||||||
<button data-viewmode="small" title="Kleine Symbole">▦</button>
|
<button id="missing-select-all" class="secondary" data-i18n="common.selectAll"></button>
|
||||||
<button data-viewmode="large" title="Große Symbole">⊞</button>
|
<select id="missing-move-library" hidden></select>
|
||||||
|
<button id="missing-bulk-delete" class="danger" hidden data-i18n="common.removeSelected"></button>
|
||||||
|
<div class="view-toggle" role="group" data-i18n-aria="list.view">
|
||||||
|
<button data-viewmode="list" data-i18n-title="list.viewList">☰</button>
|
||||||
|
<button data-viewmode="small" data-i18n-title="list.viewSmall">▦</button>
|
||||||
|
<button data-viewmode="large" data-i18n-title="list.viewLarge">⊞</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="missing-list" class="cards"></div>
|
<div id="missing-list" class="cards"></div>
|
||||||
@@ -126,15 +139,19 @@
|
|||||||
|
|
||||||
<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>
|
<select id="imported-sort" data-i18n-title="list.sort">
|
||||||
<button id="imported-bulk-delete" class="danger" hidden>
|
<option value="newest" data-i18n="list.sortNewest"></option>
|
||||||
Ausgewählte entfernen
|
<option value="title" data-i18n="list.sortTitle"></option>
|
||||||
</button>
|
<option value="author" data-i18n="list.sortAuthor"></option>
|
||||||
<div class="view-toggle" role="group" aria-label="Ansicht">
|
<option value="series" data-i18n="list.sortSeries"></option>
|
||||||
<button data-viewmode="list" title="Liste">☰</button>
|
</select>
|
||||||
<button data-viewmode="small" title="Kleine Symbole">▦</button>
|
<button id="imported-select-all" class="secondary" data-i18n="common.selectAll"></button>
|
||||||
<button data-viewmode="large" title="Große Symbole">⊞</button>
|
<button id="imported-bulk-delete" class="danger" hidden data-i18n="common.removeSelected"></button>
|
||||||
|
<div class="view-toggle" role="group" data-i18n-aria="list.view">
|
||||||
|
<button data-viewmode="list" data-i18n-title="list.viewList">☰</button>
|
||||||
|
<button data-viewmode="small" data-i18n-title="list.viewSmall">▦</button>
|
||||||
|
<button data-viewmode="large" data-i18n-title="list.viewLarge">⊞</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="imported-list" class="cards"></div>
|
<div id="imported-list" class="cards"></div>
|
||||||
@@ -142,73 +159,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 +223,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 & 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 & 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 +271,92 @@
|
|||||||
</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 & 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-series" hidden>
|
||||||
|
<div class="bar">
|
||||||
|
<button type="button" id="watch-check" data-i18n="watch.check"></button>
|
||||||
|
</div>
|
||||||
|
<div id="watch-list"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="view-activity" hidden>
|
||||||
|
<div class="table-scroll">
|
||||||
|
<table id="history-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th data-i18n="history.time"></th>
|
||||||
|
<th data-i18n="history.event"></th>
|
||||||
|
<th data-i18n="common.title"></th>
|
||||||
|
<th data-i18n="common.path"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="view-system" hidden>
|
||||||
|
<h3 data-i18n="system.health"></h3>
|
||||||
|
<ul id="health-list"></ul>
|
||||||
|
<h3 data-i18n="system.version"></h3>
|
||||||
|
<p id="system-version" class="mono"></p>
|
||||||
|
<h3 data-i18n="system.logs"></h3>
|
||||||
|
<div class="table-scroll">
|
||||||
|
<table id="log-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th data-i18n="history.time"></th>
|
||||||
|
<th data-i18n="system.level"></th>
|
||||||
|
<th data-i18n="system.source"></th>
|
||||||
|
<th data-i18n="system.message"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section id="view-settings" hidden>
|
<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 +370,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 +394,40 @@
|
|||||||
<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 & 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 & 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>
|
||||||
|
|
||||||
|
<dialog id="adopt-dialog">
|
||||||
|
<h3 id="adopt-heading"></h3>
|
||||||
|
<p class="muted" id="adopt-intro"></p>
|
||||||
|
<div id="adopt-list" class="series-list"></div>
|
||||||
|
<div class="row">
|
||||||
|
<button type="button" id="adopt-cancel" class="secondary" data-i18n="common.cancel"></button>
|
||||||
|
<button type="button" id="adopt-submit" data-i18n="adopt.submit"></button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
<div id="toast" hidden></div>
|
<div id="toast" hidden></div>
|
||||||
|
<script src="/i18n.js"></script>
|
||||||
<script src="/app.js"></script>
|
<script src="/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+15
-2
@@ -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; }
|
||||||
@@ -72,6 +74,17 @@ td.state { white-space: nowrap; font-size: 0.85rem; }
|
|||||||
td.state.done { color: var(--ok); }
|
td.state.done { color: var(--ok); }
|
||||||
td.state.failed { color: #e08585; }
|
td.state.failed { color: #e08585; }
|
||||||
td.state.running { color: var(--muted); }
|
td.state.running { color: var(--muted); }
|
||||||
|
td.state.warn { color: var(--warn); }
|
||||||
|
.badge.warn { background: var(--warn); color: #000; }
|
||||||
|
.badge.error { background: var(--danger); }
|
||||||
|
.watch { background: var(--panel); border: 1px solid var(--border); border-radius: 6px; padding: 0.7rem 0.9rem; margin-bottom: 0.8rem; }
|
||||||
|
.watch .row:first-child { margin-top: 0; }
|
||||||
|
.watch table { margin-top: 0.5rem; }
|
||||||
|
.watch .error-text { color: #e08585; }
|
||||||
|
#health-list { list-style: none; margin: 0.5rem 0 1rem; }
|
||||||
|
#health-list li { background: var(--panel); border-left: 3px solid var(--warn); border-radius: 4px; padding: 0.5rem 0.8rem; margin: 0.3rem 0; }
|
||||||
|
#health-list li.error { border-left-color: var(--danger); }
|
||||||
|
#health-list li.ok { border-left-color: var(--ok); }
|
||||||
.tag { display: inline-block; margin-left: 0.4rem; padding: 0.05rem 0.4rem; border-radius: 4px; background: var(--accent); color: #fff; font-size: 0.7rem; font-family: system-ui, sans-serif; vertical-align: 0.1em; }
|
.tag { display: inline-block; margin-left: 0.4rem; padding: 0.05rem 0.4rem; border-radius: 4px; background: var(--accent); color: #fff; font-size: 0.7rem; font-family: system-ui, sans-serif; vertical-align: 0.1em; }
|
||||||
|
|
||||||
/* view modes (Missing/Importiert) */
|
/* view modes (Missing/Importiert) */
|
||||||
@@ -102,8 +115,8 @@ td select { max-width: 340px; }
|
|||||||
.score-none { color: var(--muted); }
|
.score-none { color: var(--muted); }
|
||||||
.volume { width: 90px; }
|
.volume { width: 90px; }
|
||||||
|
|
||||||
/* dialogs */
|
/* dialogs; margin: auto undoes the * reset, which pins them to the top left */
|
||||||
dialog { background: var(--panel); color: var(--text); border: 1px solid var(--border); border-radius: 8px; padding: 1.2rem; min-width: 340px; max-width: min(640px, 92vw); }
|
dialog { margin: auto; background: var(--panel); color: var(--text); border: 1px solid var(--border); border-radius: 8px; padding: 1.2rem; min-width: 340px; max-width: min(640px, 92vw); }
|
||||||
dialog::backdrop { background: rgba(0,0,0,0.6); }
|
dialog::backdrop { background: rgba(0,0,0,0.6); }
|
||||||
dialog label { display: flex; flex-direction: column; gap: 0.2rem; margin: 0.5rem 0; font-size: 0.9rem; color: var(--muted); flex: 1; }
|
dialog label { display: flex; flex-direction: column; gap: 0.2rem; margin: 0.5rem 0; font-size: 0.9rem; color: var(--muted); flex: 1; }
|
||||||
.detail-head { display: flex; gap: 1rem; align-items: flex-start; margin-bottom: 0.5rem; }
|
.detail-head { display: flex; gap: 1rem; align-items: flex-start; margin-bottom: 0.5rem; }
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import importlib
|
import importlib
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -753,6 +754,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 +764,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 +774,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 +1179,298 @@ 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] == "-"
|
||||||
|
|
||||||
|
|
||||||
|
def test_history_records_what_happened_to_a_request(client):
|
||||||
|
root = client.tmp_path / "library" / "hist"
|
||||||
|
lib_id = client.post("/api/libraries", json={
|
||||||
|
"name": "hist", "media_type": "ebook", "root_path": str(root),
|
||||||
|
"folder_template": "{Title}", "file_template": "{Title}",
|
||||||
|
}).json()["id"]
|
||||||
|
req_id = client.post("/api/requests", json={
|
||||||
|
"library_id": lib_id, "title": "Momo"}).json()["id"]
|
||||||
|
|
||||||
|
gone = client.downloads / "gone.epub"
|
||||||
|
res = client.post("/api/import", json={"items": [{
|
||||||
|
"path": str(gone), "is_dir": False, "files": [str(gone)], "request_id": req_id,
|
||||||
|
}]}).json()["results"][0]
|
||||||
|
assert not res["ok"]
|
||||||
|
|
||||||
|
src = client.downloads / "Momo.epub"
|
||||||
|
src.write_bytes(b"x")
|
||||||
|
dest = client.post("/api/import", json={"items": [{
|
||||||
|
"path": str(src), "is_dir": False, "files": [str(src)], "request_id": req_id,
|
||||||
|
}]}).json()["results"][0]["dest"]
|
||||||
|
client.put(f"/api/requests/{req_id}", json={"library_id": lib_id, "title": "Momo 2"})
|
||||||
|
assert client.post(f"/api/requests/{req_id}/relocate").json()["moved"]
|
||||||
|
# a relocate that moves nothing is not worth a row
|
||||||
|
client.post(f"/api/requests/{req_id}/relocate")
|
||||||
|
client.delete(f"/api/requests/{req_id}")
|
||||||
|
|
||||||
|
rows = client.get("/api/history").json()
|
||||||
|
assert [r["event"] for r in rows] == ["deleted", "relocated", "imported", "failed"]
|
||||||
|
assert all(r["request_id"] == req_id for r in rows)
|
||||||
|
assert rows[0]["title"] == "Momo 2" # survives the deleted request
|
||||||
|
assert rows[2]["dest"] == dest
|
||||||
|
assert rows[3]["message"]
|
||||||
|
assert rows[0]["at"].endswith("+00:00")
|
||||||
|
|
||||||
|
|
||||||
|
def test_connections_hear_about_committed_rows(client, monkeypatch):
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from wordarr import connect
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
watcher = {"disableWatcher": False}
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
calls.append((request.method, str(request.url), request.content))
|
||||||
|
if request.url.path == "/api/libraries":
|
||||||
|
return httpx.Response(200, json={"libraries": [
|
||||||
|
{"id": "lib-audio", "name": "Audio", "settings": watcher,
|
||||||
|
"folders": [{"fullPath": str(client.tmp_path / "library")}]},
|
||||||
|
{"id": "lib-pod", "name": "Pod", "folders": [{"fullPath": "/podcasts"}]},
|
||||||
|
]})
|
||||||
|
if request.url.host == "hook":
|
||||||
|
return httpx.Response(500) # a broken webhook must not stop the scan
|
||||||
|
return httpx.Response(200)
|
||||||
|
|
||||||
|
real_client = httpx.Client
|
||||||
|
monkeypatch.setattr(connect.httpx, "Client",
|
||||||
|
lambda **kw: real_client(transport=httpx.MockTransport(handler), **kw))
|
||||||
|
monkeypatch.setattr(connect, "WEBHOOK_URL", "http://hook/in")
|
||||||
|
monkeypatch.setattr(connect, "ABS_URL", "http://abs")
|
||||||
|
monkeypatch.setattr(connect, "ABS_TOKEN", "t")
|
||||||
|
|
||||||
|
root = client.tmp_path / "library" / "conn"
|
||||||
|
lib_id = client.post("/api/libraries", json={
|
||||||
|
"name": "conn", "media_type": "ebook", "root_path": str(root),
|
||||||
|
"folder_template": "{Title}", "file_template": "{Title}",
|
||||||
|
}).json()["id"]
|
||||||
|
req_id = client.post("/api/requests", json={
|
||||||
|
"library_id": lib_id, "title": "Momo"}).json()["id"]
|
||||||
|
src = client.downloads / "Momo.epub"
|
||||||
|
src.write_bytes(b"x")
|
||||||
|
assert client.post("/api/import", json={"items": [{
|
||||||
|
"path": str(src), "is_dir": False, "files": [str(src)], "request_id": req_id,
|
||||||
|
}]}).json()["results"][0]["ok"]
|
||||||
|
|
||||||
|
# no full scan while the watcher runs: the two raced and added the book twice
|
||||||
|
dest = str(root / "Momo" / "Momo.epub")
|
||||||
|
assert [c[:2] for c in calls] == [
|
||||||
|
("POST", "http://hook/in"),
|
||||||
|
("GET", "http://abs/api/libraries"),
|
||||||
|
("POST", "http://abs/api/watcher/update"),
|
||||||
|
]
|
||||||
|
assert json.loads(calls[2][2]) == {
|
||||||
|
"libraryId": "lib-audio", "path": dest, "type": "add"}
|
||||||
|
|
||||||
|
calls.clear()
|
||||||
|
client.delete(f"/api/requests/{req_id}")
|
||||||
|
assert [c[:2] for c in calls] == [("POST", "http://hook/in")] # nothing new on disk
|
||||||
|
|
||||||
|
calls.clear()
|
||||||
|
watcher["disableWatcher"] = True
|
||||||
|
connect.notify([{"event": "imported", "dest": dest}])
|
||||||
|
assert [c[:2] for c in calls] == [
|
||||||
|
("POST", "http://hook/in"),
|
||||||
|
("GET", "http://abs/api/libraries"),
|
||||||
|
("POST", "http://abs/api/libraries/lib-audio/scan"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_bulk_move_delete_and_count(client):
|
||||||
|
def lib(name, media_type):
|
||||||
|
return client.post("/api/libraries", json={
|
||||||
|
"name": name, "media_type": media_type,
|
||||||
|
"root_path": str(client.tmp_path / name)}).json()["id"]
|
||||||
|
|
||||||
|
a, b, audio = lib("a", "ebook"), lib("b", "ebook"), lib("audio", "audiobook")
|
||||||
|
ids = [client.post("/api/requests", json={"library_id": a, "title": f"T{i}"}).json()["id"]
|
||||||
|
for i in range(3)]
|
||||||
|
assert client.get("/api/requests/count?status=missing").json() == {"count": 3}
|
||||||
|
|
||||||
|
assert client.put("/api/requests/bulk", json={
|
||||||
|
"ids": ids[:2], "library_id": audio}).status_code == 400
|
||||||
|
assert client.put("/api/requests/bulk", json={
|
||||||
|
"ids": ids[:2], "library_id": b}).json() == {"updated": 2}
|
||||||
|
assert [r["id"] for r in client.get(f"/api/requests?library_id={b}").json()] \
|
||||||
|
== sorted(ids[:2], reverse=True)
|
||||||
|
|
||||||
|
assert client.request("DELETE", "/api/requests/bulk",
|
||||||
|
json={"ids": ids[1:]}).json() == {"deleted": 2}
|
||||||
|
assert client.get("/api/requests/count?status=missing").json() == {"count": 1}
|
||||||
|
assert [r["event"] for r in client.get("/api/history").json()] == ["deleted"] * 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_adopt_files_already_in_the_library(client):
|
||||||
|
root = client.tmp_path / "audio"
|
||||||
|
lib = client.post("/api/libraries", json={
|
||||||
|
"name": "Audio", "media_type": "audiobook", "root_path": str(root)}).json()["id"]
|
||||||
|
hobbit = client.post("/api/requests", json={
|
||||||
|
"library_id": lib, "title": "Der Hobbit", "authors": "Tolkien"}).json()["id"]
|
||||||
|
known = client.post("/api/requests", json={
|
||||||
|
"library_id": lib, "title": "Momo", "authors": "Ende"}).json()["id"]
|
||||||
|
for folder in ("Tolkien/Der Hobbit", "Ende/Momo"):
|
||||||
|
(root / folder).mkdir(parents=True)
|
||||||
|
for i in (1, 2):
|
||||||
|
(root / folder / f"{i:02d}.mp3").write_bytes(b"")
|
||||||
|
(root / "Tolkien" / "Notizen.epub").write_bytes(b"") # other media type
|
||||||
|
|
||||||
|
# Momo already points at its folder, so only the Hobbit is offered
|
||||||
|
adopted = client.post(f"/api/import/library/{lib}", json={"items": [
|
||||||
|
{"path": str(root / "Ende/Momo"), "request_id": known}]}).json()
|
||||||
|
assert adopted == {"results": [{"path": str(root / "Ende/Momo"), "ok": True}]}
|
||||||
|
items = client.get(f"/api/import/library/{lib}").json()["items"]
|
||||||
|
assert [(i["name"], i["suggested_request_id"]) for i in items] == [("Der Hobbit", hobbit)]
|
||||||
|
|
||||||
|
resp = client.post(f"/api/import/library/{lib}", json={"items": [
|
||||||
|
{"path": items[0]["path"], "request_id": hobbit},
|
||||||
|
{"path": str(root / "Ende/Momo"), "request_id": known}, # no longer open
|
||||||
|
{"path": str(client.downloads), "request_id": hobbit},
|
||||||
|
]}).json()["results"]
|
||||||
|
assert [r["ok"] for r in resp] == [True, False, False]
|
||||||
|
reqs = {r["id"]: r for r in client.get("/api/requests?status=imported").json()}
|
||||||
|
assert reqs[hobbit]["imported_path"] == str(root / "Tolkien/Der Hobbit")
|
||||||
|
assert (root / "Tolkien/Der Hobbit/01.mp3").exists() # nothing moved
|
||||||
|
assert client.get(f"/api/import/library/{lib}").json()["items"] == []
|
||||||
|
assert [h["event"] for h in client.get("/api/history").json()] == ["adopted"] * 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_reports_problems_as_codes(client, monkeypatch):
|
||||||
|
from wordarr import connect
|
||||||
|
from wordarr.metadata import hardcover
|
||||||
|
monkeypatch.setattr(hardcover, "TOKEN", "")
|
||||||
|
monkeypatch.setattr(connect, "ABS_URL", "http://abs")
|
||||||
|
monkeypatch.setattr(connect, "ABS_TOKEN", "")
|
||||||
|
gone = client.tmp_path / "gone"
|
||||||
|
client.post("/api/libraries", json={
|
||||||
|
"name": "Weg", "media_type": "ebook", "root_path": str(gone)})
|
||||||
|
client.post("/api/libraries", json={
|
||||||
|
"name": "Da", "media_type": "ebook", "root_path": str(client.downloads)})
|
||||||
|
|
||||||
|
issues = client.get("/api/system/health").json()
|
||||||
|
assert {(i["code"], i.get("name")) for i in issues} == {
|
||||||
|
("library.missing", "Weg"), ("hardcover.token", None), ("abs.incomplete", None)}
|
||||||
|
|
||||||
|
monkeypatch.setattr(hardcover, "TOKEN", "x")
|
||||||
|
monkeypatch.setattr(connect, "ABS_URL", "")
|
||||||
|
gone.mkdir()
|
||||||
|
assert client.get("/api/system/health").json() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_recent_logs_newest_first(client):
|
||||||
|
import logging
|
||||||
|
logging.getLogger("wordarr.test").info("first")
|
||||||
|
logging.getLogger("wordarr.test").warning("second")
|
||||||
|
rows = client.get("/api/system/logs").json()
|
||||||
|
assert [r["message"] for r in rows[:2]] == ["second", "first"]
|
||||||
|
assert rows[0]["level"] == "WARNING"
|
||||||
|
|
||||||
|
|
||||||
|
def test_watched_series_suggests_only_new_parts(client, monkeypatch):
|
||||||
|
import asyncio
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from wordarr import db
|
||||||
|
from wordarr.api import series
|
||||||
|
from wordarr.metadata import SERIES_PROVIDERS
|
||||||
|
from wordarr.metadata.base import MetadataResult
|
||||||
|
|
||||||
|
def part(volume, external_id=""):
|
||||||
|
return MetadataResult(media_type="ebook", title=f"Band {volume}", volume=volume,
|
||||||
|
series="Saga", external_id=external_id, source="hardcover")
|
||||||
|
|
||||||
|
hits = [part(1, "a1"), part(2, "a2")]
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
async def lookup(series_id, title, language, source):
|
||||||
|
calls.append(series_id)
|
||||||
|
if hits is None:
|
||||||
|
raise RuntimeError("offline")
|
||||||
|
return hits
|
||||||
|
|
||||||
|
monkeypatch.setitem(SERIES_PROVIDERS, "ebook", lookup)
|
||||||
|
lib = client.post("/api/libraries", json={
|
||||||
|
"name": "Saga", "media_type": "ebook", "root_path": str(client.tmp_path)}).json()
|
||||||
|
client.post("/api/requests", json={"library_id": lib["id"], "title": "Band 3", "volume": 3})
|
||||||
|
watch = {"media_type": "ebook", "library_id": lib["id"], "series_id": "s1",
|
||||||
|
"source": "hardcover", "title": "Saga"}
|
||||||
|
|
||||||
|
resp = client.post("/api/series", json=watch)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["new"] == [] # what exists today is not news
|
||||||
|
assert client.post("/api/series", json=watch).status_code == 409
|
||||||
|
|
||||||
|
hits += [part(3), part(4, "a4"), part(5, "a5")] # 3 is requested already
|
||||||
|
[w] = client.post("/api/series/check").json()
|
||||||
|
new = {p["title"]: p["id"] for p in w["new"]}
|
||||||
|
assert set(new) == {"Band 4", "Band 5"}
|
||||||
|
|
||||||
|
client.post("/api/series/parts", json={"ids": [new["Band 4"]], "action": "request"})
|
||||||
|
client.post("/api/series/parts", json={"ids": [new["Band 5"]], "action": "ignore"})
|
||||||
|
missing = client.get("/api/requests?status=missing").json()
|
||||||
|
assert {r["title"] for r in missing} == {"Band 3", "Band 4"}
|
||||||
|
assert client.post("/api/series/check").json()[0]["new"] == []
|
||||||
|
|
||||||
|
hits = None
|
||||||
|
[w] = client.post("/api/series/check").json()
|
||||||
|
assert w["error"] == "offline"
|
||||||
|
assert client.post("/api/series", json={**watch, "series_id": "s2"}).status_code == 502
|
||||||
|
|
||||||
|
# the daily run only picks up what was checked more than a day ago
|
||||||
|
hits, calls[:] = [], []
|
||||||
|
asyncio.run(series.check_due())
|
||||||
|
assert calls == []
|
||||||
|
with db.SessionLocal() as session:
|
||||||
|
session.get(db.WatchedSeries, w["id"]).checked_at = (
|
||||||
|
datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=2))
|
||||||
|
session.commit()
|
||||||
|
asyncio.run(series.check_due())
|
||||||
|
assert calls == ["s1"]
|
||||||
|
|
||||||
|
assert client.delete(f"/api/series/{w['id']}").status_code == 200
|
||||||
|
assert client.get("/api/series").json() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_covers_are_fetched_once_and_kept(client, monkeypatch):
|
||||||
|
import httpx
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
async def fake_get(self, url, **kwargs):
|
||||||
|
calls.append(url)
|
||||||
|
if "dead" in url:
|
||||||
|
raise httpx.ConnectError("gone")
|
||||||
|
ctype = "text/html" if "page" in url else "image/png"
|
||||||
|
return httpx.Response(200, headers={"content-type": ctype}, content=b"PNG",
|
||||||
|
request=httpx.Request("GET", url))
|
||||||
|
|
||||||
|
monkeypatch.setattr(httpx.AsyncClient, "get", fake_get)
|
||||||
|
from wordarr import config
|
||||||
|
monkeypatch.setattr(config, "CONFIG_DIR", client.tmp_path / "config")
|
||||||
|
lib = client.post("/api/libraries", json={
|
||||||
|
"name": "Covers", "media_type": "ebook", "root_path": str(client.tmp_path)}).json()
|
||||||
|
|
||||||
|
def request(cover_url):
|
||||||
|
return client.post("/api/requests?allow_duplicate=true", json={
|
||||||
|
"library_id": lib["id"], "title": "Buch", "cover_url": cover_url}).json()["id"]
|
||||||
|
|
||||||
|
good = request("https://covers.example/a.png")
|
||||||
|
for _ in range(2):
|
||||||
|
resp = client.get(f"/api/requests/{good}/cover")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.content == b"PNG"
|
||||||
|
assert resp.headers["content-type"] == "image/png"
|
||||||
|
assert calls == ["https://covers.example/a.png"] # the second came from disk
|
||||||
|
assert [p.suffix for p in (client.tmp_path / "config" / "covers").iterdir()] == [".png"]
|
||||||
|
|
||||||
|
for url in ("https://covers.example/page", "https://covers.example/dead", "", "file:///etc/passwd"):
|
||||||
|
assert client.get(f"/api/requests/{request(url)}/cover").status_code == 404
|
||||||
@@ -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")]
|
||||||
@@ -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")
|
||||||
@@ -95,6 +97,8 @@ def test_import_dialog_opens_and_closes(page):
|
|||||||
page.click("[data-quick='0']")
|
page.click("[data-quick='0']")
|
||||||
page.wait_for_selector("#quick-dialog[open]")
|
page.wait_for_selector("#quick-dialog[open]")
|
||||||
assert page.locator("#quick-dialog").is_visible()
|
assert page.locator("#quick-dialog").is_visible()
|
||||||
|
box = page.locator("#quick-dialog").bounding_box()
|
||||||
|
assert abs(box["x"] + box["width"] / 2 - 600) < 10 # centered in the viewport
|
||||||
# everything reachable, nothing cut off at the bottom
|
# everything reachable, nothing cut off at the bottom
|
||||||
for sel in ("#quick-m-submit", "#quick-close"):
|
for sel in ("#quick-m-submit", "#quick-close"):
|
||||||
box = page.locator(sel).bounding_box()
|
box = page.locator(sel).bounding_box()
|
||||||
@@ -124,3 +128,60 @@ def test_format_tags_and_the_format_filter(page):
|
|||||||
rows = page.locator("#import-table tbody tr")
|
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()
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import datetime
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from pydantic import BaseModel, ConfigDict, field_serializer
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..db import History, get_session
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/history", tags=["history"])
|
||||||
|
|
||||||
|
|
||||||
|
class HistoryOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
at: datetime.datetime
|
||||||
|
event: str
|
||||||
|
request_id: int | None
|
||||||
|
title: str
|
||||||
|
media_type: str
|
||||||
|
source: str
|
||||||
|
dest: str
|
||||||
|
message: str
|
||||||
|
|
||||||
|
# SQLite drops the zone, and a bare timestamp reads as local time in JS
|
||||||
|
@field_serializer("at")
|
||||||
|
def _utc(self, at: datetime.datetime) -> str:
|
||||||
|
return at.replace(tzinfo=datetime.UTC).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[HistoryOut])
|
||||||
|
def list_history(limit: int = 200, session: Session = Depends(get_session)):
|
||||||
|
"""Newest first."""
|
||||||
|
stmt = select(History).order_by(History.id.desc()).limit(min(limit, 1000))
|
||||||
|
return session.scalars(stmt).all()
|
||||||
+102
-6
@@ -1,13 +1,13 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from .. import config
|
from .. import config, connect
|
||||||
from ..db import BookRequest, get_session
|
from ..db import BookRequest, Library, get_session, record
|
||||||
from ..fsnames import decode_path, readable
|
from ..fsnames import decode_path, is_broken, readable
|
||||||
from ..importer import matcher, mover, scanner
|
from ..importer import matcher, mover, scanner
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/import", tags=["import"])
|
router = APIRouter(prefix="/api/import", tags=["import"])
|
||||||
@@ -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):
|
||||||
@@ -54,8 +58,9 @@ class ImportIn(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("")
|
@router.post("")
|
||||||
def do_import(data: ImportIn, session: Session = Depends(get_session)):
|
def do_import(data: ImportIn, background: BackgroundTasks,
|
||||||
results = []
|
session: Session = Depends(get_session)):
|
||||||
|
results, rows = [], []
|
||||||
download_root = Path(config.DOWNLOAD_DIR).resolve()
|
download_root = Path(config.DOWNLOAD_DIR).resolve()
|
||||||
for item in data.items:
|
for item in data.items:
|
||||||
# the client hands back whatever the scan produced, base64 included
|
# the client hands back whatever the scan produced, base64 included
|
||||||
@@ -87,9 +92,12 @@ def do_import(data: ImportIn, session: Session = Depends(get_session)):
|
|||||||
try:
|
try:
|
||||||
dest = mover.append_to_import(item.files, req, req.library)
|
dest = mover.append_to_import(item.files, req, req.library)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
rows.append(record(session, "failed", req, source=shown, message=str(exc)))
|
||||||
|
session.commit()
|
||||||
results.append({"path": shown, "ok": False, "error": str(exc)})
|
results.append({"path": shown, "ok": False, "error": str(exc)})
|
||||||
continue
|
continue
|
||||||
req.imported_path = dest
|
req.imported_path = dest
|
||||||
|
rows.append(record(session, "appended", req, source=shown, dest=dest))
|
||||||
session.commit()
|
session.commit()
|
||||||
results.append({"path": shown, "ok": True, "dest": dest, "appended": True})
|
results.append({"path": shown, "ok": True, "dest": dest, "appended": True})
|
||||||
continue
|
continue
|
||||||
@@ -101,15 +109,103 @@ 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
|
||||||
|
rows.append(record(session, "failed", req, source=shown, message=str(exc)))
|
||||||
|
session.commit()
|
||||||
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"
|
||||||
req.imported_path = dest
|
req.imported_path = dest
|
||||||
|
rows.append(record(session, "imported", req, source=shown, dest=dest))
|
||||||
session.commit()
|
session.commit()
|
||||||
results.append({"path": shown, "ok": True, "dest": dest})
|
results.append({"path": shown, "ok": True, "dest": dest})
|
||||||
if not results:
|
if not results:
|
||||||
raise HTTPException(400, "nothing to import")
|
raise HTTPException(400, "nothing to import")
|
||||||
|
connect.schedule(background, rows)
|
||||||
|
return {"results": results}
|
||||||
|
|
||||||
|
|
||||||
|
def _library(session: Session, library_id: int) -> Library:
|
||||||
|
lib = session.get(Library, library_id)
|
||||||
|
if not lib:
|
||||||
|
raise HTTPException(404, "library not found")
|
||||||
|
return lib
|
||||||
|
|
||||||
|
|
||||||
|
def _open_for(session: Session, lib: Library) -> list[BookRequest]:
|
||||||
|
missing = session.scalars(select(BookRequest).where(
|
||||||
|
BookRequest.status == "missing", BookRequest.library_id == lib.id)).all()
|
||||||
|
return list(missing) + [r for r in _orphaned(session) if r.library_id == lib.id]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/library/{library_id}")
|
||||||
|
def scan_library(library_id: int, session: Session = Depends(get_session)):
|
||||||
|
"""Files already in the library that no request points at, matched against
|
||||||
|
the library's open requests, so an existing collection need not be imported
|
||||||
|
again."""
|
||||||
|
lib = _library(session, library_id)
|
||||||
|
imported = session.scalars(select(BookRequest.imported_path).where(
|
||||||
|
BookRequest.status == "imported", BookRequest.imported_path != "")).all()
|
||||||
|
covered = {Path(p) for p in imported}
|
||||||
|
# a folder holding an imported file, or a file inside an imported folder
|
||||||
|
above = {a for p in covered for a in p.parents}
|
||||||
|
|
||||||
|
def known(item: dict) -> bool:
|
||||||
|
p = Path(decode_path(item["path"]))
|
||||||
|
return p in covered or p in above or any(a in covered for a in p.parents)
|
||||||
|
|
||||||
|
items = [i for i in scanner.scan(Path(lib.root_path))
|
||||||
|
if i["media_type"] == lib.media_type and not known(i)]
|
||||||
|
open_reqs = _open_for(session, lib)
|
||||||
|
return {"root_path": lib.root_path,
|
||||||
|
"items": matcher.best_matches(items, open_reqs),
|
||||||
|
"requests": [{"id": r.id, "title": r.title, "authors": r.authors}
|
||||||
|
for r in open_reqs]}
|
||||||
|
|
||||||
|
|
||||||
|
class AdoptItem(BaseModel):
|
||||||
|
path: str
|
||||||
|
request_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class AdoptIn(BaseModel):
|
||||||
|
items: list[AdoptItem]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/library/{library_id}")
|
||||||
|
def adopt(library_id: int, data: AdoptIn, background: BackgroundTasks,
|
||||||
|
session: Session = Depends(get_session)):
|
||||||
|
"""Point open requests at files that are already in place. Nothing moves."""
|
||||||
|
lib = _library(session, library_id)
|
||||||
|
root = Path(lib.root_path).resolve()
|
||||||
|
open_ids = {r.id for r in _open_for(session, lib)}
|
||||||
|
results, rows = [], []
|
||||||
|
for item in data.items:
|
||||||
|
path = decode_path(item.path)
|
||||||
|
shown = readable(path)
|
||||||
|
req = session.get(BookRequest, item.request_id)
|
||||||
|
if is_broken(path):
|
||||||
|
error = "file name is not valid UTF-8, rename it first"
|
||||||
|
elif root not in Path(path).resolve().parents:
|
||||||
|
error = "path outside the library"
|
||||||
|
elif not Path(path).exists():
|
||||||
|
error = "path no longer exists"
|
||||||
|
elif not req or req.id not in open_ids:
|
||||||
|
error = f"request #{item.request_id} is not open in this library"
|
||||||
|
else:
|
||||||
|
error = ""
|
||||||
|
if error:
|
||||||
|
results.append({"path": shown, "ok": False, "error": error})
|
||||||
|
continue
|
||||||
|
req.status, req.imported_path = "imported", path
|
||||||
|
rows.append(record(session, "adopted", req, dest=path))
|
||||||
|
open_ids.discard(req.id) # two files for one request: the first wins
|
||||||
|
results.append({"path": shown, "ok": True})
|
||||||
|
session.commit()
|
||||||
|
connect.schedule(background, rows)
|
||||||
return {"results": results}
|
return {"results": results}
|
||||||
@@ -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):
|
||||||
|
|||||||
+77
-10
@@ -1,12 +1,13 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||||
from pydantic import BaseModel
|
from fastapi.responses import FileResponse
|
||||||
from sqlalchemy import select
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from .. import config
|
from .. import config, connect, covers
|
||||||
from ..db import BookRequest, Library, get_session
|
from ..db import BookRequest, Library, get_session, record
|
||||||
from ..importer import mover, tagger
|
from ..importer import mover, tagger
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/requests", tags=["requests"])
|
router = APIRouter(prefix="/api/requests", tags=["requests"])
|
||||||
@@ -34,8 +35,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:
|
||||||
@@ -177,6 +177,51 @@ def create_bulk_items(data: BulkItemsIn, session: Session = Depends(get_session)
|
|||||||
return {"created": [_out(r) for r in created], "skipped": skipped}
|
return {"created": [_out(r) for r in created], "skipped": skipped}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/count")
|
||||||
|
def count_requests(status: str | None = None, session: Session = Depends(get_session)):
|
||||||
|
stmt = select(func.count()).select_from(BookRequest)
|
||||||
|
if status:
|
||||||
|
stmt = stmt.where(BookRequest.status == status)
|
||||||
|
return {"count": session.scalar(stmt)}
|
||||||
|
|
||||||
|
|
||||||
|
class BulkIdsIn(BaseModel):
|
||||||
|
ids: list[int]
|
||||||
|
|
||||||
|
|
||||||
|
class BulkUpdateIn(BulkIdsIn):
|
||||||
|
library_id: int
|
||||||
|
|
||||||
|
|
||||||
|
# registered before the /{request_id} routes, which would try "bulk" as an id
|
||||||
|
@router.put("/bulk")
|
||||||
|
def update_bulk(data: BulkUpdateIn, session: Session = Depends(get_session)):
|
||||||
|
"""Move requests to another library. Imported files stay where they are
|
||||||
|
until the request is relocated."""
|
||||||
|
lib = session.get(Library, data.library_id)
|
||||||
|
if not lib:
|
||||||
|
raise HTTPException(404, "library not found")
|
||||||
|
reqs = session.scalars(select(BookRequest).where(BookRequest.id.in_(data.ids))).all()
|
||||||
|
if any(r.media_type != lib.media_type for r in reqs):
|
||||||
|
raise HTTPException(400, "library has a different media type")
|
||||||
|
for r in reqs:
|
||||||
|
r.library_id = lib.id
|
||||||
|
session.commit()
|
||||||
|
return {"updated": len(reqs)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/bulk")
|
||||||
|
def delete_bulk(data: BulkIdsIn, background: BackgroundTasks,
|
||||||
|
session: Session = Depends(get_session)):
|
||||||
|
reqs = session.scalars(select(BookRequest).where(BookRequest.id.in_(data.ids))).all()
|
||||||
|
rows = [record(session, "deleted", r, source=r.imported_path) for r in reqs]
|
||||||
|
for r in reqs:
|
||||||
|
session.delete(r)
|
||||||
|
session.commit()
|
||||||
|
connect.schedule(background, rows)
|
||||||
|
return {"deleted": len(reqs)}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{request_id}", response_model=RequestOut)
|
@router.put("/{request_id}", response_model=RequestOut)
|
||||||
def update_request(request_id: int, data: RequestIn, session: Session = Depends(get_session)):
|
def update_request(request_id: int, data: RequestIn, session: Session = Depends(get_session)):
|
||||||
req = session.get(BookRequest, request_id)
|
req = session.get(BookRequest, request_id)
|
||||||
@@ -193,8 +238,18 @@ def update_request(request_id: int, data: RequestIn, session: Session = Depends(
|
|||||||
return _out(req)
|
return _out(req)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{request_id}/cover")
|
||||||
|
async def request_cover(request_id: int, session: Session = Depends(get_session)):
|
||||||
|
req = session.get(BookRequest, request_id)
|
||||||
|
if not req or not (path := await covers.cached(req.cover_url)):
|
||||||
|
raise HTTPException(404, "no cover")
|
||||||
|
# the UI puts the cover url into the query, so a changed cover is a new url
|
||||||
|
return FileResponse(path, headers={"Cache-Control": "max-age=31536000, immutable"})
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{request_id}/retag")
|
@router.post("/{request_id}/retag")
|
||||||
def retag_request(request_id: int, session: Session = Depends(get_session)):
|
def retag_request(request_id: int, background: BackgroundTasks,
|
||||||
|
session: Session = Depends(get_session)):
|
||||||
"""Rewrite the audio tags of an already imported request from its current
|
"""Rewrite the audio tags of an already imported request from its current
|
||||||
metadata (e.g. after adding series/volume/narrator later)."""
|
metadata (e.g. after adding series/volume/narrator later)."""
|
||||||
req = session.get(BookRequest, request_id)
|
req = session.get(BookRequest, request_id)
|
||||||
@@ -214,15 +269,22 @@ def retag_request(request_id: int, session: Session = Depends(get_session)):
|
|||||||
raise HTTPException(404, f"no audio files in {path}")
|
raise HTTPException(404, f"no audio files in {path}")
|
||||||
for i, f in enumerate(files, 1):
|
for i, f in enumerate(files, 1):
|
||||||
tagger.tag_audio(f, req, track=i, total=len(files))
|
tagger.tag_audio(f, req, track=i, total=len(files))
|
||||||
|
row = record(session, "retagged", req, dest=str(path))
|
||||||
|
session.commit()
|
||||||
|
connect.schedule(background, [row])
|
||||||
return {"ok": True, "files": len(files)}
|
return {"ok": True, "files": len(files)}
|
||||||
if path.is_file():
|
if path.is_file():
|
||||||
tagger.tag_audio(path, req)
|
tagger.tag_audio(path, req)
|
||||||
|
row = record(session, "retagged", req, dest=str(path))
|
||||||
|
session.commit()
|
||||||
|
connect.schedule(background, [row])
|
||||||
return {"ok": True, "files": 1}
|
return {"ok": True, "files": 1}
|
||||||
raise HTTPException(404, f"imported path no longer exists: {path}")
|
raise HTTPException(404, f"imported path no longer exists: {path}")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{request_id}/relocate")
|
@router.post("/{request_id}/relocate")
|
||||||
def relocate_request(request_id: int, session: Session = Depends(get_session)):
|
def relocate_request(request_id: int, background: BackgroundTasks,
|
||||||
|
session: Session = Depends(get_session)):
|
||||||
"""Re-apply the library's naming scheme to an imported title, e.g. after
|
"""Re-apply the library's naming scheme to an imported title, e.g. after
|
||||||
fixing a series that Audible had filed differently."""
|
fixing a series that Audible had filed differently."""
|
||||||
req = session.get(BookRequest, request_id)
|
req = session.get(BookRequest, request_id)
|
||||||
@@ -238,15 +300,20 @@ def relocate_request(request_id: int, session: Session = Depends(get_session)):
|
|||||||
except FileExistsError as exc:
|
except FileExistsError as exc:
|
||||||
raise HTTPException(409, str(exc))
|
raise HTTPException(409, str(exc))
|
||||||
req.imported_path = dest
|
req.imported_path = dest
|
||||||
|
rows = [record(session, "relocated", req, source=old, dest=dest)] if dest != old else []
|
||||||
session.commit()
|
session.commit()
|
||||||
|
connect.schedule(background, rows)
|
||||||
return {"ok": True, "moved": dest != old, "from": old, "dest": dest}
|
return {"ok": True, "moved": dest != old, "from": old, "dest": dest}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{request_id}")
|
@router.delete("/{request_id}")
|
||||||
def delete_request(request_id: int, session: Session = Depends(get_session)):
|
def delete_request(request_id: int, background: BackgroundTasks,
|
||||||
|
session: Session = Depends(get_session)):
|
||||||
req = session.get(BookRequest, request_id)
|
req = session.get(BookRequest, request_id)
|
||||||
if not req:
|
if not req:
|
||||||
raise HTTPException(404, "request not found")
|
raise HTTPException(404, "request not found")
|
||||||
|
row = record(session, "deleted", req, source=req.imported_path)
|
||||||
session.delete(req)
|
session.delete(req)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
connect.schedule(background, [row])
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
+10
-6
@@ -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}")
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import asyncio
|
||||||
|
import datetime
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import or_, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .. import db
|
||||||
|
from ..db import BookRequest, Library, SeriesPart, WatchedSeries, get_session
|
||||||
|
from ..metadata import SERIES_PROVIDERS
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/series", tags=["series"])
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
PART_FIELDS = ("title", "authors", "narrator", "external_id", "year", "series",
|
||||||
|
"volume", "cover_url")
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime.datetime:
|
||||||
|
return datetime.datetime.now(datetime.UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def _key(r) -> str:
|
||||||
|
return r.external_id or f"{r.title.strip().lower()}|{r.volume}"
|
||||||
|
|
||||||
|
|
||||||
|
async def check_watch(session: Session, w: WatchedSeries) -> None:
|
||||||
|
"""Look the series up again. The first lookup only records what exists, so
|
||||||
|
just later parts become suggestions; so do parts nobody has requested yet."""
|
||||||
|
try:
|
||||||
|
hits = await SERIES_PROVIDERS[w.media_type](w.series_id, w.title, w.language, w.source)
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("series %r lookup failed: %s", w.title, exc)
|
||||||
|
w.error, w.checked_at = str(exc), _now()
|
||||||
|
return
|
||||||
|
initial = not w.parts
|
||||||
|
reqs = session.scalars(select(BookRequest).where(BookRequest.media_type == w.media_type)).all()
|
||||||
|
requested = {r.external_id for r in reqs if r.external_id}
|
||||||
|
requested |= {(r.title.strip().lower(), r.volume) for r in reqs}
|
||||||
|
seen = {p.key for p in w.parts}
|
||||||
|
for hit in hits:
|
||||||
|
if (key := _key(hit)) in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
known = initial or hit.external_id in requested or (
|
||||||
|
hit.title.strip().lower(), hit.volume) in requested
|
||||||
|
w.parts.append(SeriesPart(key=key, state="known" if known else "new",
|
||||||
|
**{f: getattr(hit, f) for f in PART_FIELDS}))
|
||||||
|
w.error, w.checked_at = "", _now()
|
||||||
|
|
||||||
|
|
||||||
|
async def check_due() -> None:
|
||||||
|
cutoff = (_now() - datetime.timedelta(days=1)).replace(tzinfo=None) # SQLite stores naive
|
||||||
|
with db.SessionLocal() as session:
|
||||||
|
due = session.scalars(select(WatchedSeries).where(
|
||||||
|
or_(WatchedSeries.checked_at.is_(None), WatchedSeries.checked_at < cutoff))).all()
|
||||||
|
for w in due:
|
||||||
|
await check_watch(session, w)
|
||||||
|
session.commit()
|
||||||
|
if due:
|
||||||
|
log.info("checked %d watched series", len(due))
|
||||||
|
|
||||||
|
|
||||||
|
async def run_daily() -> None:
|
||||||
|
"""Wakes hourly and checks what is a day old, so restarts do not reset the clock."""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await check_due()
|
||||||
|
except Exception:
|
||||||
|
log.exception("series check failed")
|
||||||
|
await asyncio.sleep(3600)
|
||||||
|
|
||||||
|
|
||||||
|
def _out(w: WatchedSeries) -> dict:
|
||||||
|
checked = w.checked_at and w.checked_at.replace(tzinfo=datetime.UTC).isoformat()
|
||||||
|
return {
|
||||||
|
"id": w.id, "media_type": w.media_type, "library_id": w.library_id,
|
||||||
|
"library_name": w.library.name, "series_id": w.series_id, "source": w.source,
|
||||||
|
"title": w.title, "checked_at": checked, "error": w.error,
|
||||||
|
"new": [{"id": p.id, **{f: getattr(p, f) for f in PART_FIELDS}}
|
||||||
|
for p in w.parts if p.state == "new"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _all(session: Session) -> list[dict]:
|
||||||
|
return [_out(w) for w in session.scalars(select(WatchedSeries).order_by(WatchedSeries.title))]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def list_watches(session: Session = Depends(get_session)):
|
||||||
|
return _all(session)
|
||||||
|
|
||||||
|
|
||||||
|
class WatchIn(BaseModel):
|
||||||
|
media_type: str
|
||||||
|
library_id: int
|
||||||
|
series_id: str
|
||||||
|
source: str = ""
|
||||||
|
title: str
|
||||||
|
language: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("")
|
||||||
|
async def create_watch(data: WatchIn, session: Session = Depends(get_session)):
|
||||||
|
if data.media_type not in SERIES_PROVIDERS:
|
||||||
|
raise HTTPException(400, f"no series lookup for media_type: {data.media_type}")
|
||||||
|
lib = session.get(Library, data.library_id)
|
||||||
|
if not lib:
|
||||||
|
raise HTTPException(404, "library not found")
|
||||||
|
if lib.media_type != data.media_type:
|
||||||
|
raise HTTPException(400, "library has a different media type")
|
||||||
|
if session.scalar(select(WatchedSeries).where(
|
||||||
|
WatchedSeries.media_type == data.media_type,
|
||||||
|
WatchedSeries.source == data.source,
|
||||||
|
WatchedSeries.series_id == data.series_id)):
|
||||||
|
raise HTTPException(409, "series is already watched")
|
||||||
|
w = WatchedSeries(**data.model_dump())
|
||||||
|
session.add(w)
|
||||||
|
await check_watch(session, w)
|
||||||
|
if w.error: # without a first lookup every part would turn up as new later
|
||||||
|
session.rollback()
|
||||||
|
raise HTTPException(502, f"metadata provider error: {w.error}")
|
||||||
|
session.commit()
|
||||||
|
return _out(w)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{watch_id}")
|
||||||
|
def delete_watch(watch_id: int, session: Session = Depends(get_session)):
|
||||||
|
w = session.get(WatchedSeries, watch_id)
|
||||||
|
if not w:
|
||||||
|
raise HTTPException(404, "series not found")
|
||||||
|
session.delete(w)
|
||||||
|
session.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/check")
|
||||||
|
async def check_all(session: Session = Depends(get_session)):
|
||||||
|
for w in session.scalars(select(WatchedSeries)).all():
|
||||||
|
await check_watch(session, w)
|
||||||
|
session.commit()
|
||||||
|
return _all(session)
|
||||||
|
|
||||||
|
|
||||||
|
class PartsIn(BaseModel):
|
||||||
|
ids: list[int]
|
||||||
|
action: str # request | ignore
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/parts")
|
||||||
|
def handle_parts(data: PartsIn, session: Session = Depends(get_session)):
|
||||||
|
if data.action not in ("request", "ignore"):
|
||||||
|
raise HTTPException(400, f"invalid action: {data.action}")
|
||||||
|
parts = session.scalars(select(SeriesPart).where(
|
||||||
|
SeriesPart.id.in_(data.ids), SeriesPart.state == "new")).all()
|
||||||
|
for p in parts:
|
||||||
|
if data.action == "request":
|
||||||
|
session.add(BookRequest(media_type=p.watch.media_type, library_id=p.watch.library_id,
|
||||||
|
**{f: getattr(p, f) for f in PART_FIELDS}))
|
||||||
|
p.state = "requested" if data.action == "request" else "ignored"
|
||||||
|
session.commit()
|
||||||
|
return {"done": len(parts)}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .. import config, connect, logs
|
||||||
|
from ..db import Library, get_session
|
||||||
|
from ..metadata import hardcover
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/system", tags=["system"])
|
||||||
|
|
||||||
|
|
||||||
|
def _dir_problem(path: Path) -> str | None:
|
||||||
|
if not path.is_dir():
|
||||||
|
return "missing"
|
||||||
|
if not os.access(path, os.W_OK | os.X_OK):
|
||||||
|
return "readonly"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
def health(session: Session = Depends(get_session)):
|
||||||
|
"""Problems only, as codes the UI translates; an empty list means healthy."""
|
||||||
|
issues = []
|
||||||
|
if problem := _dir_problem(config.DOWNLOAD_DIR):
|
||||||
|
issues.append({"code": f"download.{problem}", "level": "error",
|
||||||
|
"path": str(config.DOWNLOAD_DIR)})
|
||||||
|
for lib in session.scalars(select(Library).order_by(Library.name)):
|
||||||
|
if problem := _dir_problem(Path(lib.root_path)):
|
||||||
|
issues.append({"code": f"library.{problem}", "level": "error",
|
||||||
|
"name": lib.name, "path": lib.root_path})
|
||||||
|
if not hardcover.TOKEN:
|
||||||
|
issues.append({"code": "hardcover.token", "level": "warning"})
|
||||||
|
if bool(connect.ABS_URL) != bool(connect.ABS_TOKEN):
|
||||||
|
issues.append({"code": "abs.incomplete", "level": "warning"})
|
||||||
|
return issues
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/logs")
|
||||||
|
def recent_logs():
|
||||||
|
"""Newest first."""
|
||||||
|
return list(reversed(logs.RECORDS))
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""Tell other services about history rows once they are committed: a generic
|
||||||
|
webhook gets every row, Audiobookshelf the new files of the libraries holding
|
||||||
|
them. Runs as a background task, so a slow or dead service never fails the
|
||||||
|
request that caused it; errors only reach the log."""
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import BackgroundTasks
|
||||||
|
|
||||||
|
from .db import History
|
||||||
|
|
||||||
|
ABS_URL = os.environ.get("WORDARR_ABS_URL", "").strip().rstrip("/")
|
||||||
|
ABS_TOKEN = os.environ.get("WORDARR_ABS_TOKEN", "").strip()
|
||||||
|
WEBHOOK_URL = os.environ.get("WORDARR_WEBHOOK_URL", "").strip()
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def schedule(background: BackgroundTasks, rows: list[History]) -> None:
|
||||||
|
"""Queue the notification; it runs after the response is sent."""
|
||||||
|
events = [
|
||||||
|
{"event": r.event, "request_id": r.request_id, "title": r.title,
|
||||||
|
"media_type": r.media_type, "source": r.source, "dest": r.dest,
|
||||||
|
"message": r.message}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
if events:
|
||||||
|
background.add_task(notify, events)
|
||||||
|
|
||||||
|
|
||||||
|
def notify(events: list[dict]) -> None:
|
||||||
|
with httpx.Client(timeout=10) as client:
|
||||||
|
if WEBHOOK_URL:
|
||||||
|
for e in events:
|
||||||
|
try:
|
||||||
|
client.post(WEBHOOK_URL, json=e).raise_for_status()
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
log.warning("webhook %s failed: %s", WEBHOOK_URL, exc)
|
||||||
|
dests = [e["dest"] for e in events if e["dest"] and e["event"] != "failed"]
|
||||||
|
if ABS_URL and ABS_TOKEN and dests:
|
||||||
|
try:
|
||||||
|
_scan_audiobookshelf(client, dests)
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
log.warning("audiobookshelf scan failed: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_audiobookshelf(client: httpx.Client, dests: list[str]) -> None:
|
||||||
|
"""Tell the libraries whose folders contain a destination about the new
|
||||||
|
files. That needs both containers to mount the media under the same path."""
|
||||||
|
headers = {"Authorization": f"Bearer {ABS_TOKEN}"}
|
||||||
|
resp = client.get(f"{ABS_URL}/api/libraries", headers=headers)
|
||||||
|
resp.raise_for_status()
|
||||||
|
for lib in resp.json().get("libraries") or []:
|
||||||
|
folders = [str(f.get("fullPath") or "").rstrip("/")
|
||||||
|
for f in lib.get("folders") or []]
|
||||||
|
mine = [d for d in dests
|
||||||
|
if any(f and (d == f or d.startswith(f + "/")) for f in folders)]
|
||||||
|
if not mine:
|
||||||
|
continue
|
||||||
|
if (lib.get("settings") or {}).get("disableWatcher"):
|
||||||
|
# the watcher endpoint drops paths of unwatched libraries
|
||||||
|
client.post(f"{ABS_URL}/api/libraries/{lib.get('id')}/scan",
|
||||||
|
headers=headers).raise_for_status()
|
||||||
|
log.info("audiobookshelf scans library %s", lib.get("name"))
|
||||||
|
continue
|
||||||
|
# a full scan racing the watcher adds the same folder twice, and it
|
||||||
|
# walks the whole library; this queues the files with the watcher
|
||||||
|
for path in _files(mine):
|
||||||
|
client.post(f"{ABS_URL}/api/watcher/update", headers=headers,
|
||||||
|
json={"libraryId": lib.get("id"), "path": path,
|
||||||
|
"type": "add"}).raise_for_status()
|
||||||
|
log.info("audiobookshelf watches new files in library %s", lib.get("name"))
|
||||||
|
|
||||||
|
|
||||||
|
def _files(dests: list[str]) -> list[str]:
|
||||||
|
"""Imports are flat: a destination is a file or a folder of files."""
|
||||||
|
out = []
|
||||||
|
for d in map(Path, dests):
|
||||||
|
if d.is_file():
|
||||||
|
out.append(str(d))
|
||||||
|
elif d.is_dir():
|
||||||
|
out += sorted(str(f) for f in d.iterdir() if f.is_file())
|
||||||
|
return out
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""Covers are fetched once and kept under the config dir, so a dead link or a
|
||||||
|
slow host does not take the picture with it."""
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
import mimetypes
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from . import config
|
||||||
|
|
||||||
|
MAX_BYTES = 5 * 1024 * 1024
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# shortcut: covers of deleted requests stay on disk, add a sweep if the dir grows
|
||||||
|
async def cached(url: str) -> Path | None:
|
||||||
|
"""The file for this url, downloaded on first use; None if it cannot be had."""
|
||||||
|
if not url.startswith(("http://", "https://")):
|
||||||
|
return None
|
||||||
|
folder = config.CONFIG_DIR / "covers"
|
||||||
|
name = hashlib.sha256(url.encode()).hexdigest()
|
||||||
|
if hit := next(folder.glob(name + ".*"), None):
|
||||||
|
return hit
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
|
||||||
|
resp = await client.get(url)
|
||||||
|
resp.raise_for_status()
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
log.warning("cover %s failed: %s", url, exc)
|
||||||
|
return None
|
||||||
|
ctype = resp.headers.get("content-type", "").split(";")[0].strip()
|
||||||
|
if not ctype.startswith("image/") or len(resp.content) > MAX_BYTES:
|
||||||
|
log.warning("cover %s is no usable image (%s, %d bytes)", url, ctype, len(resp.content))
|
||||||
|
return None
|
||||||
|
folder.mkdir(parents=True, exist_ok=True)
|
||||||
|
# written aside and renamed, so a parallel request never serves half a file
|
||||||
|
with tempfile.NamedTemporaryFile(dir=folder, suffix=".tmp", delete=False) as tmp:
|
||||||
|
tmp.write(resp.content)
|
||||||
|
path = folder / (name + (mimetypes.guess_extension(ctype) or ".img"))
|
||||||
|
Path(tmp.name).chmod(0o644) # NamedTemporaryFile creates 0600
|
||||||
|
Path(tmp.name).replace(path)
|
||||||
|
return path
|
||||||
@@ -23,6 +23,8 @@ class Library(Base):
|
|||||||
language: Mapped[str] = mapped_column(String, default="")
|
language: Mapped[str] = mapped_column(String, default="")
|
||||||
|
|
||||||
requests: Mapped[list["BookRequest"]] = relationship(back_populates="library")
|
requests: Mapped[list["BookRequest"]] = relationship(back_populates="library")
|
||||||
|
watches: Mapped[list["WatchedSeries"]] = relationship(
|
||||||
|
back_populates="library", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
class BookRequest(Base):
|
class BookRequest(Base):
|
||||||
@@ -48,6 +50,75 @@ class BookRequest(Base):
|
|||||||
library: Mapped[Library] = relationship(back_populates="requests")
|
library: Mapped[Library] = relationship(back_populates="requests")
|
||||||
|
|
||||||
|
|
||||||
|
class History(Base):
|
||||||
|
"""One row per thing done to a request. Title and type are copied, and
|
||||||
|
there is no foreign key, so an entry outlives a deleted request."""
|
||||||
|
|
||||||
|
__tablename__ = "history"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
at: Mapped[datetime.datetime] = mapped_column(
|
||||||
|
default=lambda: datetime.datetime.now(datetime.UTC)
|
||||||
|
)
|
||||||
|
# imported | appended | relocated | retagged | deleted | failed
|
||||||
|
event: Mapped[str] = mapped_column(String)
|
||||||
|
request_id: Mapped[int | None] = mapped_column(default=None)
|
||||||
|
title: Mapped[str] = mapped_column(String)
|
||||||
|
media_type: Mapped[str] = mapped_column(String)
|
||||||
|
source: Mapped[str] = mapped_column(String, default="")
|
||||||
|
dest: Mapped[str] = mapped_column(String, default="")
|
||||||
|
message: Mapped[str] = mapped_column(String, default="")
|
||||||
|
|
||||||
|
|
||||||
|
class WatchedSeries(Base):
|
||||||
|
"""A series looked up again now and then; new parts become suggestions."""
|
||||||
|
|
||||||
|
__tablename__ = "watched_series"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
media_type: Mapped[str] = mapped_column(String)
|
||||||
|
library_id: Mapped[int] = mapped_column(ForeignKey("libraries.id"))
|
||||||
|
series_id: Mapped[str] = mapped_column(String)
|
||||||
|
source: Mapped[str] = mapped_column(String, default="")
|
||||||
|
title: Mapped[str] = mapped_column(String)
|
||||||
|
language: Mapped[str] = mapped_column(String, default="")
|
||||||
|
checked_at: Mapped[datetime.datetime | None] = mapped_column(default=None)
|
||||||
|
error: Mapped[str] = mapped_column(String, default="")
|
||||||
|
|
||||||
|
library: Mapped[Library] = relationship(back_populates="watches")
|
||||||
|
parts: Mapped[list["SeriesPart"]] = relationship(
|
||||||
|
back_populates="watch", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class SeriesPart(Base):
|
||||||
|
"""Every part a watch has seen, so a later lookup can tell what is new."""
|
||||||
|
|
||||||
|
__tablename__ = "series_parts"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
watch_id: Mapped[int] = mapped_column(ForeignKey("watched_series.id"))
|
||||||
|
key: Mapped[str] = mapped_column(String)
|
||||||
|
state: Mapped[str] = mapped_column(String) # known | new | requested | ignored
|
||||||
|
title: Mapped[str] = mapped_column(String)
|
||||||
|
authors: Mapped[str] = mapped_column(String, default="")
|
||||||
|
narrator: Mapped[str] = mapped_column(String, default="")
|
||||||
|
external_id: Mapped[str] = mapped_column(String, default="")
|
||||||
|
year: Mapped[int | None] = mapped_column(default=None)
|
||||||
|
series: Mapped[str] = mapped_column(String, default="")
|
||||||
|
volume: Mapped[int | None] = mapped_column(default=None)
|
||||||
|
cover_url: Mapped[str] = mapped_column(String, default="")
|
||||||
|
|
||||||
|
watch: Mapped[WatchedSeries] = relationship(back_populates="parts")
|
||||||
|
|
||||||
|
|
||||||
|
def record(session, event: str, req: BookRequest, **fields: str) -> History:
|
||||||
|
"""Add a history row; it is saved with the caller's next commit."""
|
||||||
|
row = History(event=event, request_id=req.id, title=req.title,
|
||||||
|
media_type=req.media_type, **fields)
|
||||||
|
session.add(row)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
_engine = None
|
_engine = None
|
||||||
SessionLocal = None
|
SessionLocal = None
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""Keep the latest log records in memory for the system page."""
|
||||||
|
import collections
|
||||||
|
import datetime
|
||||||
|
import logging
|
||||||
|
|
||||||
|
# shortcut: lost on restart, write to /config if older entries are ever needed
|
||||||
|
RECORDS: collections.deque[dict] = collections.deque(maxlen=200)
|
||||||
|
|
||||||
|
|
||||||
|
class _Buffer(logging.Handler):
|
||||||
|
def emit(self, record: logging.LogRecord) -> None:
|
||||||
|
RECORDS.append({
|
||||||
|
"at": datetime.datetime.fromtimestamp(record.created, datetime.UTC).isoformat(),
|
||||||
|
"level": record.levelname,
|
||||||
|
"logger": record.name,
|
||||||
|
"message": self.format(record),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def install() -> None:
|
||||||
|
"""A handler on root would silence the last-resort stderr output, so
|
||||||
|
basicConfig keeps the container log. uvicorn's loggers do not propagate,
|
||||||
|
its errors are caught on uvicorn.error itself."""
|
||||||
|
if any(isinstance(h, _Buffer) for h in logging.getLogger().handlers):
|
||||||
|
return
|
||||||
|
logging.basicConfig(format="%(levelname)s:%(name)s: %(message)s")
|
||||||
|
logging.getLogger("wordarr").setLevel(logging.INFO)
|
||||||
|
buffer = _Buffer(logging.INFO)
|
||||||
|
logging.getLogger().addHandler(buffer)
|
||||||
|
logging.getLogger("uvicorn.error").addHandler(buffer)
|
||||||
+16
-2
@@ -1,17 +1,22 @@
|
|||||||
|
import asyncio
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
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, logs, version
|
||||||
from .api import fs, imports, libraries, requests, search
|
from .api import fs, history, imports, libraries, requests, search, series, system
|
||||||
|
|
||||||
|
logs.install()
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
db.init_db()
|
db.init_db()
|
||||||
|
watcher = asyncio.create_task(series.run_daily())
|
||||||
yield
|
yield
|
||||||
|
watcher.cancel()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="wordarr", lifespan=lifespan)
|
app = FastAPI(title="wordarr", lifespan=lifespan)
|
||||||
@@ -21,6 +26,15 @@ app.include_router(search.router)
|
|||||||
app.include_router(requests.router)
|
app.include_router(requests.router)
|
||||||
app.include_router(imports.router)
|
app.include_router(imports.router)
|
||||||
app.include_router(fs.router)
|
app.include_router(fs.router)
|
||||||
|
app.include_router(history.router)
|
||||||
|
app.include_router(system.router)
|
||||||
|
app.include_router(series.router)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/version")
|
||||||
|
def read_version():
|
||||||
|
return {"version": version.VERSION, "build": version.BUILD,
|
||||||
|
"built_at": version.BUILT_AT}
|
||||||
|
|
||||||
|
|
||||||
class RevalidatingStatics(StaticFiles):
|
class RevalidatingStatics(StaticFiles):
|
||||||
|
|||||||
@@ -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
@@ -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])
|
||||||
@@ -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()
|
||||||
Reference in new issue
Block a user