diff --git a/README.md b/README.md
index 0060687..70cc936 100644
--- a/README.md
+++ b/README.md
@@ -5,8 +5,9 @@ Sonarr/Radarr-Style Request- & Import-Manager für **Ebooks**, **Comics/Mangas**
## Funktionsweise
1. **Anfragen**: Im Web-UI per Titel/Autor/ISBN suchen (Ebooks: Open Library · Audiobooks: Audible · Manga: AniList) oder manuell anlegen. Beim Request wählst du die **Ziel-Library** (z.B. deine 8 Bookorbit-Libraries oder die 3 Audiobookshelf-Libraries english/adult/kids). Der Titel erscheint als **Missing**.
+ Gehört ein Audiobook-Treffer zu einer Serie, legt **„Ganze Serie…"** alle Folgen auf einmal an (Folgennummer + echter Titel von Audible, optional auf einen Folgenbereich eingegrenzt). Audible hat keine Serien-Abfrage — wordarr sammelt die Folgen über mehrere Suchläufe ein, einzelne können fehlen und werden im Dialog als Lücke angezeigt.
2. **Download-Ordner**: wordarr überwacht keinen Downloader aktiv — du legst Dateien selbst in den Download-Ordner (`/mnt/downloads`).
-3. **Import missing**: Im Tab *Import* den Ordner scannen. wordarr schlägt per Fuzzy-Matching Datei→Request-Zuordnungen vor; du bestätigst oder korrigierst. Beim Import wird die Datei nach dem Namensschema der Library **umbenannt und verschoben**. Ordner mit mehreren Audio-Dateien werden als ein Audiobook behandelt (`Titel - Part 01.mp3`, …). Auch Ordner, die in Disc-Unterordner (`CD1`, `CD 2`, `Disc 3`, `Teil 1`) aufgeteilt sind, gelten als **ein** Audiobook — der Ordnername darüber liefert den Titel, die Parts werden über alle Discs hinweg durchnummeriert.
+3. **Import missing**: Im Tab *Import* den Ordner scannen. wordarr schlägt per Fuzzy-Matching Datei→Request-Zuordnungen vor (bei Serien zählt die Folgennummer im Ordnernamen plus der Serienname im Pfad, `???` und `Fragezeichen` gelten als dasselbe); du bestätigst oder korrigierst. Beim Import wird die Datei nach dem Namensschema der Library **umbenannt und verschoben**. Ordner mit mehreren Audio-Dateien werden als ein Audiobook behandelt (`Titel - Part 01.mp3`, …). Auch Ordner, die in Disc-Unterordner (`CD`, `CD1`, `CD 2`, `Disc 3`, `Teil 1`) aufgeteilt sind, gelten als **ein** Audiobook — der Ordnername darüber liefert den Titel, die Parts werden über alle Discs hinweg durchnummeriert. Unterordner ohne Audio (`Cover`, Scans, Booklets) werden dabei ignoriert.
## Setup (Docker)
diff --git a/static/app.js b/static/app.js
index ca917da..b23a004 100644
--- a/static/app.js
+++ b/static/app.js
@@ -219,11 +219,15 @@ $("#search-form").addEventListener("submit", async (e) => {
+ ${r.series_id ? `` : ""}
`
)
.join("");
+ box.querySelectorAll("[data-series]").forEach((btn) =>
+ btn.addEventListener("click", () => openSeriesDialog(results[btn.dataset.series]))
+ );
box.querySelectorAll("[data-req]").forEach((btn) =>
btn.addEventListener("click", async () => {
const i = btn.dataset.req;
@@ -255,6 +259,119 @@ $("#search-form").addEventListener("submit", async (e) => {
}
});
+// ---- request a whole series ----
+let seriesEpisodes = [];
+
+function renderSeriesList() {
+ const from = parseInt($("#series-from").value);
+ const to = parseInt($("#series-to").value);
+ const inRange = (e) =>
+ (isNaN(from) || (e.volume != null && e.volume >= from)) &&
+ (isNaN(to) || (e.volume != null && e.volume <= to));
+ let previous = null;
+ $("#series-list").innerHTML = seriesEpisodes
+ .map((e, i) => {
+ // make holes in the numbering visible - the crawl cannot reach every episode
+ let gap = "";
+ if (e.volume != null && previous != null && e.volume > previous + 1) {
+ const missing = e.volume - previous - 1;
+ gap = `
… ${missing} Folge${missing > 1 ? "n" : ""} nicht bei Audible gefunden (${previous + 1}–${e.volume - 1})
`;
+ }
+ if (e.volume != null) previous = e.volume;
+ return `${gap}`;
+ })
+ .join("");
+ updateSeriesCount();
+}
+
+function seriesChecked() {
+ return [...$("#series-list").querySelectorAll("input:checked")].map(
+ (c) => seriesEpisodes[c.dataset.ep]
+ );
+}
+
+function updateSeriesCount() {
+ const n = seriesChecked().length;
+ const btn = $("#series-submit");
+ btn.textContent = n ? `${n} anfragen` : "Anfragen";
+ btn.disabled = n === 0;
+}
+
+async function openSeriesDialog(result) {
+ const dlg = $("#series-dialog");
+ $("#series-title").textContent = result.series || "Serie";
+ $("#series-info").innerHTML = ' Folgen werden gesucht…';
+ $("#series-list").innerHTML = "";
+ $("#series-from").value = "";
+ $("#series-to").value = "";
+ $("#series-library").innerHTML = libOptions("audiobook") || "";
+ $("#series-submit").disabled = true;
+ dlg.showModal();
+ try {
+ seriesEpisodes = await api(
+ `/api/search/series?series_id=${encodeURIComponent(result.series_id)}` +
+ `&title=${encodeURIComponent(result.series || result.title)}`
+ );
+ } catch (err) {
+ $("#series-info").textContent = err.message;
+ return;
+ }
+ const numbered = seriesEpisodes.filter((e) => e.volume != null);
+ $("#series-info").textContent =
+ `${seriesEpisodes.length} Folgen gefunden` +
+ (numbered.length ? ` (Nr. ${numbered[0].volume}–${numbered[numbered.length - 1].volume})` : "") +
+ ". Audible hat keine Serien-Abfrage — einzelne Folgen können fehlen.";
+ renderSeriesList();
+}
+
+$("#series-from").addEventListener("input", renderSeriesList);
+$("#series-to").addEventListener("input", renderSeriesList);
+$("#series-list").addEventListener("change", updateSeriesCount);
+$("#series-all").addEventListener("click", () => {
+ $("#series-list").querySelectorAll("input").forEach((c) => (c.checked = true));
+ updateSeriesCount();
+});
+$("#series-none").addEventListener("click", () => {
+ $("#series-list").querySelectorAll("input").forEach((c) => (c.checked = false));
+ updateSeriesCount();
+});
+$("#series-cancel").addEventListener("click", () => $("#series-dialog").close());
+
+$("#series-submit").addEventListener("click", async () => {
+ const libId = $("#series-library").value;
+ if (!libId) { toast("Erst eine Audiobook-Library anlegen (Tab Libraries)", true); return; }
+ const items = seriesChecked().map((e) => ({
+ title: e.title, authors: e.authors, narrator: e.narrator || "",
+ external_id: e.external_id, year: e.year, series: e.series,
+ volume: e.volume, cover_url: e.cover_url,
+ }));
+ const btn = $("#series-submit");
+ btn.disabled = true;
+ btn.innerHTML = ' Lege an…';
+ try {
+ const res = await api("/api/requests/bulk-items", {
+ method: "POST",
+ body: JSON.stringify({ library_id: parseInt(libId), items }),
+ });
+ setLastLib("audiobook", libId);
+ toast(
+ `${res.created.length} Anfragen angelegt` +
+ (res.skipped ? `, ${res.skipped} bereits vorhanden` : "")
+ );
+ $("#series-dialog").close();
+ refreshMissingBadge();
+ } catch (err) {
+ toast(err.message, true);
+ } finally {
+ btn.disabled = false;
+ updateSeriesCount();
+ }
+});
+
// ---- manual request ----
$("#manual-btn").addEventListener("click", () => {
const dlg = $("#manual-dialog");
diff --git a/static/index.html b/static/index.html
index 59f16c5..04a6cd0 100644
--- a/static/index.html
+++ b/static/index.html
@@ -73,6 +73,25 @@
+
+
diff --git a/static/style.css b/static/style.css
index 4595e90..b009bce 100644
--- a/static/style.css
+++ b/static/style.css
@@ -97,6 +97,13 @@ dialog label { display: flex; flex-direction: column; gap: 0.2rem; margin: 0.5re
}
@keyframes spin { to { transform: rotate(360deg); } }
#quick-dialog { min-width: 480px; }
+#series-dialog { min-width: 520px; }
+.series-list { max-height: 45vh; overflow-y: auto; margin: 0.6rem 0; border: 1px solid var(--border); border-radius: 6px; }
+.series-list label { flex-direction: row; align-items: center; gap: 0.5rem; margin: 0; padding: 0.35rem 0.6rem; color: var(--text); font-size: 0.9rem; }
+.series-list label:nth-child(even) { background: rgba(255,255,255,0.03); }
+.series-list input[type=checkbox] { flex: none; }
+.series-list .num { flex: none; min-width: 3.2em; color: var(--muted); font-variant-numeric: tabular-nums; }
+.series-list .gap { padding: 0.35rem 0.6rem; color: var(--muted); font-size: 0.85rem; }
#quick-results { display: flex; flex-direction: column; max-height: 50vh; overflow-y: auto; margin-top: 0.6rem; }
.ok { color: var(--ok); }
.error { color: #e08585; }
@@ -110,5 +117,6 @@ h3, h4 { margin: 0.8rem 0 0.6rem; }
main { padding: 0.8rem; }
.cards { grid-template-columns: 1fr; }
#quick-dialog { min-width: 0; width: 92vw; }
+ #series-dialog { min-width: 0; width: 92vw; }
td select { max-width: 200px; }
}
diff --git a/tests/test_import_flow.py b/tests/test_import_flow.py
index ffcebb2..2628126 100644
--- a/tests/test_import_flow.py
+++ b/tests/test_import_flow.py
@@ -311,3 +311,106 @@ def test_natural_track_order_in_flat_folder(client):
(folder / f"track{i}.mp3").write_bytes(b"")
item = client.get("/api/import/scan").json()["items"][0]
assert [Path(f).name for f in item["files"]] == ["track1.mp3", "track2.mp3", "track10.mp3"]
+
+
+def test_real_world_series_tree(client):
+ """Downloads laid out as Serie/Folgen///CD/*.mp3,
+ with a Cover/ folder next to the CD folder."""
+ base = client.downloads / "Die Drei Fragezeichen" / "Folgen" / "3478632869 001-010"
+ episodes = ["001 - Der Super - Papagei", "002 - Der Phantomsee", "003 - Der Karpatenhund"]
+ for ep in episodes:
+ cd = base / ep / "CD"
+ cd.mkdir(parents=True)
+ for i in (1, 2, 10):
+ (cd / f"{i:02d} Track.mp3").write_bytes(b"")
+ cover = base / ep / "Cover"
+ cover.mkdir()
+ (cover / "front.jpg").write_bytes(b"")
+
+ items = client.get("/api/import/scan").json()["items"]
+ assert [i["name"] for i in items] == episodes
+ for item in items:
+ assert item["is_dir"] and len(item["files"]) == 3
+ assert [Path(f).name for f in item["files"]] == ["01 Track.mp3", "02 Track.mp3", "10 Track.mp3"]
+ assert item["rel_dir"] == "Die Drei Fragezeichen/Folgen/3478632869 001-010"
+
+
+def test_two_discs_next_to_cover(client):
+ folder = client.downloads / "099 - Die Villa der Toten"
+ for disc in ("CD 1", "CD 2"):
+ (folder / disc).mkdir(parents=True)
+ (folder / disc / "track.mp3").write_bytes(b"")
+ (folder / "Cover").mkdir()
+ (folder / "Cover" / "back.png").write_bytes(b"")
+
+ items = client.get("/api/import/scan").json()["items"]
+ assert len(items) == 1
+ assert items[0]["name"] == "099 - Die Villa der Toten"
+ assert [Path(f).parent.name for f in items[0]["files"]] == ["CD 1", "CD 2"]
+
+
+def test_subfolder_with_own_content_is_not_a_disc(client):
+ """A real subfolder (an episode) must keep its own identity."""
+ folder = client.downloads / "Sammlung"
+ for ep in ("Folge 1", "Folge 2"):
+ (folder / ep).mkdir(parents=True)
+ for i in (1, 2):
+ (folder / ep / f"t{i}.mp3").write_bytes(b"")
+ items = client.get("/api/import/scan").json()["items"]
+ assert sorted(i["name"] for i in items) == ["Folge 1", "Folge 2"]
+
+
+def test_bulk_items_creates_series_and_skips_duplicates(client):
+ lib_id = client.post("/api/libraries", json={
+ "name": "DDF", "media_type": "audiobook",
+ "root_path": str(client.tmp_path / "ddf"),
+ }).json()["id"]
+ items = [{"title": f"Die drei ??? Folge {n}", "series": "Die drei ???",
+ "volume": n, "external_id": f"ASIN{n}"} for n in range(1, 6)]
+
+ res = client.post("/api/requests/bulk-items",
+ json={"library_id": lib_id, "items": items}).json()
+ assert len(res["created"]) == 5 and res["skipped"] == 0
+ assert res["created"][0]["media_type"] == "audiobook"
+ assert res["created"][0]["volume"] == 1
+
+ # same call again: known external_ids are skipped, new ones still land
+ items.append({"title": "Die drei ??? Folge 6", "series": "Die drei ???",
+ "volume": 6, "external_id": "ASIN6"})
+ res2 = client.post("/api/requests/bulk-items",
+ json={"library_id": lib_id, "items": items}).json()
+ assert res2["skipped"] == 5 and len(res2["created"]) == 1
+ assert len(client.get("/api/requests", params={"status": "missing"}).json()) == 6
+
+
+def test_bulk_items_series_then_scan_matches_by_episode_number(client):
+ """The whole point: bulk-request a series, then let the scan assign folders."""
+ lib_id = client.post("/api/libraries", json={
+ "name": "DDF", "media_type": "audiobook",
+ "root_path": str(client.tmp_path / "ddf"),
+ }).json()["id"]
+ titles = {1: "Die drei ??? und der Super-Papagei",
+ 4: "Die drei ??? und die schwarze Katze",
+ 8: "Die drei ??? und der grüne Geist"}
+ created = client.post("/api/requests/bulk-items", json={
+ "library_id": lib_id,
+ "items": [{"title": t, "series": "Die drei ???", "volume": n,
+ "external_id": f"ASIN{n}"} for n, t in titles.items()],
+ }).json()["created"]
+ by_volume = {r["volume"]: r["id"] for r in created}
+
+ base = client.downloads / "Die Drei Fragezeichen" / "Folgen" / "3478632869 001-010"
+ folders = {1: "001 - Der Super - Papagei", 4: "004 - Die schwarzn Katze",
+ 8: "008 - Der gruene Geist"}
+ for n, folder in folders.items():
+ cd = base / folder / "CD"
+ cd.mkdir(parents=True)
+ (cd / "01.mp3").write_bytes(b"")
+ (cd / "02.mp3").write_bytes(b"")
+
+ items = client.get("/api/import/scan").json()["items"]
+ assert len(items) == 3
+ for item in items:
+ n = int(item["name"][:3])
+ assert item["suggested_request_id"] == by_volume[n], item
+ assert item["score"] >= 90
diff --git a/tests/test_matcher.py b/tests/test_matcher.py
index d3e688a..3e9d077 100644
--- a/tests/test_matcher.py
+++ b/tests/test_matcher.py
@@ -1,6 +1,6 @@
from types import SimpleNamespace
-from wordarr.importer.matcher import best_matches, normalize
+from wordarr.importer.matcher import best_matches, normalize, score
def req(id, title, authors="", media_type="ebook"):
@@ -70,3 +70,28 @@ def test_digit_inside_word_is_not_a_volume():
r3 = req(2, "Die dr3i - Folge 3", media_type="audiobook")
r3.volume = 3
assert score("DiE DR3i - 05 - Der Fall", r) > score("DiE DR3i - 05 - Der Fall", r3)
+
+
+def test_series_in_folder_path_carries_the_match():
+ """Downloads named "001 - Titel" only match via the series in their path."""
+ r = req(1, "Die drei ??? Folge 001", media_type="audiobook")
+ r.volume, r.series = 1, "Die drei ???"
+ item = {"path": "/d/x", "name": "001 - Der Super - Papagei",
+ "rel_dir": "Die Drei Fragezeichen/Folgen/3478632869 001-010",
+ "media_type": "audiobook", "is_dir": True, "files": ["/d/x/a.mp3"]}
+ out = best_matches([item], [r])
+ assert out[0]["suggested_request_id"] == 1
+ assert out[0]["score"] >= 90
+ # without the series context it stays a weak fuzzy guess
+ assert score("001 - Der Super - Papagei", r) < 90
+
+
+def test_questionmarks_equal_fragezeichen():
+ assert normalize("Die drei ???") == normalize("Die Drei Fragezeichen")
+
+
+def test_disc_count_is_not_an_episode_number():
+ r = req(1, "Die drei ??? und der Phantomsee", media_type="audiobook")
+ r.volume = 2
+ # "(2 CDs)" must not satisfy the volume check of episode 2
+ assert score("Die drei Fragezeichen 001 - Super-Papagei (2 CDs)", r) == 40.0
diff --git a/wordarr/api/requests.py b/wordarr/api/requests.py
index 5821238..95456a2 100644
--- a/wordarr/api/requests.py
+++ b/wordarr/api/requests.py
@@ -12,8 +12,7 @@ from ..importer import tagger
router = APIRouter(prefix="/api/requests", tags=["requests"])
-class RequestIn(BaseModel):
- library_id: int
+class BulkItem(BaseModel):
title: str
authors: str = ""
narrator: str = ""
@@ -24,6 +23,10 @@ class RequestIn(BaseModel):
cover_url: str = ""
+class RequestIn(BulkItem):
+ library_id: int
+
+
class RequestOut(RequestIn):
id: int
media_type: str
@@ -127,6 +130,53 @@ def create_bulk(data: BulkRequestIn, session: Session = Depends(get_session)):
return [_out(r) for r in reqs]
+class BulkItemsIn(BaseModel):
+ library_id: int
+ items: list[BulkItem]
+
+
+@router.post("/bulk-items")
+def create_bulk_items(data: BulkItemsIn, session: Session = Depends(get_session)):
+ """Create many requests at once from ready-made metadata (e.g. a whole
+ Audible series). Entries that already exist as missing are skipped."""
+ lib = session.get(Library, data.library_id)
+ if not lib:
+ raise HTTPException(404, "library not found")
+ if len(data.items) > 1000:
+ raise HTTPException(400, "at most 1000 requests per bulk call")
+
+ open_reqs = session.scalars(
+ select(BookRequest).where(
+ BookRequest.status == "missing",
+ BookRequest.media_type == lib.media_type,
+ )
+ ).all()
+ known_ids = {r.external_id for r in open_reqs if r.external_id}
+ known_titles = {
+ (r.title.strip().lower(), r.narrator or "", r.volume) for r in open_reqs
+ }
+
+ created, skipped = [], 0
+ for item in data.items:
+ key = (item.title.strip().lower(), item.narrator or "", item.volume)
+ if (item.external_id and item.external_id in known_ids) or key in known_titles:
+ skipped += 1
+ continue
+ known_ids.add(item.external_id)
+ known_titles.add(key)
+ req = BookRequest(
+ media_type=lib.media_type,
+ library_id=lib.id,
+ **item.model_dump(),
+ )
+ session.add(req)
+ created.append(req)
+ session.commit()
+ for r in created:
+ session.refresh(r)
+ return {"created": [_out(r) for r in created], "skipped": skipped}
+
+
@router.put("/{request_id}", response_model=RequestOut)
def update_request(request_id: int, data: RequestIn, session: Session = Depends(get_session)):
req = session.get(BookRequest, request_id)
diff --git a/wordarr/api/search.py b/wordarr/api/search.py
index 0b141ce..ed1a90d 100644
--- a/wordarr/api/search.py
+++ b/wordarr/api/search.py
@@ -1,6 +1,6 @@
from fastapi import APIRouter, HTTPException
-from ..metadata import PROVIDERS
+from ..metadata import PROVIDERS, audible
from ..metadata.base import MetadataResult
router = APIRouter(prefix="/api/search", tags=["search"])
@@ -15,3 +15,16 @@ async def search(media_type: str, q: str):
return await provider(q)
except Exception as exc:
raise HTTPException(502, f"metadata provider error: {exc}")
+
+
+@router.get("/series", response_model=list[MetadataResult])
+async def series(series_id: str, title: str):
+ """All episodes of an Audible series, sorted by episode number. Audible has
+ no series endpoint, so this is a paged keyword crawl and may miss a few
+ episodes - those can still be added by hand."""
+ if not series_id:
+ raise HTTPException(400, "series_id required")
+ try:
+ return await audible.series_episodes(series_id, title)
+ except Exception as exc:
+ raise HTTPException(502, f"metadata provider error: {exc}")
diff --git a/wordarr/importer/matcher.py b/wordarr/importer/matcher.py
index ab4cca0..facbe78 100644
--- a/wordarr/importer/matcher.py
+++ b/wordarr/importer/matcher.py
@@ -16,8 +16,12 @@ def _deleet(s: str) -> str:
def normalize(s: str) -> str:
s = s.lower()
- s = re.sub(r"[._\-\[\]()]+", " ", s)
+ s = s.replace("???", " fragezeichen ") # "Die drei ???" == "Die drei Fragezeichen"
+ s = re.sub(r"[._\-\[\]()?]+", " ", s)
s = re.sub(r"\b(epub|pdf|mobi|azw3|m4b|mp3|flac|cbz|cbr|retail|unabridged|audiobook|ebook|v\d+)\b", " ", s)
+ # disc noise: "(2 CDs)", "CD1", "Disc 3" - the count is not an episode number
+ s = re.sub(r"\b\d+\s*(?:cds?|discs?|disks?)\b", " ", s)
+ s = re.sub(r"\b(?:cd|disc|disk|dvd)[\s._-]*\d*\b", " ", s)
s = _deleet(s)
s = re.sub(r"\s+", " ", s)
return s.strip()
@@ -28,7 +32,7 @@ def _numbers(s: str) -> set[int]:
return {int(n) for n in re.findall(r"\b\d+\b", normalize(s))}
-def score(item_name: str, request) -> float:
+def score(item_name: str, request, rel_dir: str = "") -> float:
target = normalize(f"{request.authors} {request.title}")
name = normalize(item_name)
s = fuzz.token_set_ratio(name, target)
@@ -47,6 +51,13 @@ def score(item_name: str, request) -> float:
result = min(100.0, result + 5)
elif nums:
result = min(result, 40.0)
+ # series in the folder path + matching episode number is a strong signal
+ # even when the file name carries only the episode title ("001 - Titel")
+ series = getattr(request, "series", "") or ""
+ if series and request.volume in nums:
+ context = normalize(f"{rel_dir} {item_name}")
+ if fuzz.partial_ratio(normalize(series), context) > 85:
+ result = max(result, 90.0)
return result
@@ -57,7 +68,7 @@ def best_matches(items: list[dict], requests) -> list[dict]:
candidates = [r for r in requests if r.media_type == item["media_type"]]
best, best_score = None, 0.0
for r in candidates:
- sc = score(item["name"], r)
+ sc = score(item["name"], r, item.get("rel_dir", ""))
if sc > best_score:
best, best_score = r, sc
out.append({
diff --git a/wordarr/importer/scanner.py b/wordarr/importer/scanner.py
index 092dab1..d8d3dac 100644
--- a/wordarr/importer/scanner.py
+++ b/wordarr/importer/scanner.py
@@ -3,10 +3,11 @@ from pathlib import Path
from .. import config
-# "CD1", "CD 2", "Disc_03", "Teil 1", "Part 2" as folder name. A bare number is
-# deliberately not a disc: those folders are usually episodes of a series.
+# "CD", "CD1", "CD 2", "Disc_03", "Teil 1", "Part 2" as folder name. The number is
+# optional (single-disc rips just use "CD"). A bare number is deliberately not a
+# disc: those folders are usually episodes of a series.
DISC_DIR_RE = re.compile(
- r"^(?:cd|disc|disk|dvd|teil|part|vol|volume)[\s._-]*(\d{1,3})$",
+ r"^(?:cd|disc|disk|dvd|teil|part|vol|volume)[\s._-]*(\d{1,3})?$",
re.IGNORECASE,
)
@@ -28,8 +29,12 @@ def natural_key(name: str) -> list:
def disc_number(name: str) -> int | None:
+ """Disc index of a disc folder name, 0 for an unnumbered "CD". None if the
+ name is not a disc folder at all."""
m = DISC_DIR_RE.match(name.strip())
- return int(m.group(1)) if m else None
+ if not m:
+ return None
+ return int(m.group(1)) if m.group(1) else 0
def scan(root: Path, split_dirs: bool = False) -> list[dict]:
@@ -52,17 +57,23 @@ def scan(root: Path, split_dirs: bool = False) -> list[dict]:
def subdirs(d: Path) -> list[Path]:
return [s for s in d.iterdir() if s.is_dir() and not s.name.startswith(".")]
+ def has_audio(d: Path) -> bool:
+ return any(
+ f.suffix.lower() in config.AUDIOBOOK_EXTENSIONS
+ for f in d.rglob("*") if f.is_file()
+ )
+
def disc_audio(d: Path) -> list[Path] | None:
- """Audio files of d's disc subfolders (CD1, CD2, …), in disc order.
- None if d is not a multi-disc folder."""
- subs = subdirs(d)
- if not subs:
- return None
+ """Audio files of d's disc subfolders ("CD", "CD1", "CD2", …), in disc
+ order. Subfolders without audio (Cover, Scans, …) are ignored. None if d
+ is not a disc-split folder."""
discs = []
- for s in subs:
+ for s in subdirs(d):
+ if not has_audio(s):
+ continue # artwork/booklet folder, not part of the audiobook
num = disc_number(s.name)
- if num is None or not audio_files(s):
- return None # not purely a disc split -> walk normally
+ if num is None:
+ return None # a real subfolder -> not a disc split, walk normally
discs.append((num, s))
files = []
for _, s in sorted(discs, key=lambda t: (t[0], natural_key(t[1].name))):
diff --git a/wordarr/metadata/audible.py b/wordarr/metadata/audible.py
index 4d742a1..58da99c 100644
--- a/wordarr/metadata/audible.py
+++ b/wordarr/metadata/audible.py
@@ -8,14 +8,30 @@ from .base import MetadataResult
# Comma-separated marketplace TLDs, first entries rank first in the results.
REGIONS = [r.strip() for r in os.environ.get("WORDARR_AUDIBLE_REGIONS", "de,com").split(",")]
+RESPONSE_GROUPS = "media,contributors,product_desc,product_attrs,series"
+PAGE_SIZE = 50
+# Audible has no "list a series" endpoint (series_asin is rejected), so a series
+# is collected by paging through keyword searches and keeping the products that
+# carry its asin. One query never returns the whole catalog, hence the variants.
+SERIES_QUERY_SUFFIXES = ["", " und der", " und die", " und das", " Folge"]
+SERIES_MAX_PAGES = 12
+SERIES_MAX_CALLS = 90
+# Audible drops requests when hit with a dozen at once, so pages go out in chunks
+SERIES_CHUNK = 4
-async def _search_region(client: httpx.AsyncClient, tld: str, query: str) -> list[dict]:
+
+async def _query(client: httpx.AsyncClient, tld: str, query: str, num_results: int = 10,
+ page: int = 1, sort: str | None = "Relevance") -> list[dict]:
params = {
"keywords": query,
- "num_results": 10,
- "response_groups": "media,contributors,product_desc,product_attrs,series",
- "products_sort_by": "Relevance",
+ "num_results": num_results,
+ "page": page,
+ "response_groups": RESPONSE_GROUPS,
}
+ # sorting by relevance helps the top-10 search but measurably shrinks what a
+ # deep paged sweep returns, so the series crawl leaves it off
+ if sort:
+ params["products_sort_by"] = sort
try:
resp = await client.get(f"https://api.audible.{tld}/1.0/catalog/products", params=params)
resp.raise_for_status()
@@ -24,10 +40,33 @@ async def _search_region(client: httpx.AsyncClient, tld: str, query: str) -> lis
return []
+def _to_result(p: dict) -> MetadataResult:
+ images = p.get("product_images") or {}
+ release = p.get("release_date") or ""
+ series_list = p.get("series") or []
+ first = series_list[0] if series_list else {}
+ try:
+ volume = int(float(first.get("sequence", "")))
+ except (TypeError, ValueError):
+ volume = None
+ return MetadataResult(
+ media_type="audiobook",
+ title=p.get("title", ""),
+ authors=", ".join(a.get("name", "") for a in p.get("authors") or []),
+ narrator=", ".join(n.get("name", "") for n in p.get("narrators") or []),
+ series=first.get("title", ""),
+ series_id=first.get("asin", ""),
+ volume=volume,
+ external_id=p.get("asin", ""),
+ year=int(release[:4]) if release[:4].isdigit() else None,
+ cover_url=next(iter(images.values()), ""),
+ )
+
+
async def search(query: str) -> list[MetadataResult]:
async with httpx.AsyncClient(timeout=15) as client:
region_results = await asyncio.gather(
- *(_search_region(client, tld, query) for tld in REGIONS)
+ *(_query(client, tld, query) for tld in REGIONS)
)
results = []
@@ -38,26 +77,56 @@ async def search(query: str) -> list[MetadataResult]:
if asin in seen_asins:
continue
seen_asins.add(asin)
- images = p.get("product_images") or {}
- release = p.get("release_date") or ""
- series_list = p.get("series") or []
- series = series_list[0].get("title", "") if series_list else ""
- seq = series_list[0].get("sequence", "") if series_list else ""
- try:
- volume = int(float(seq))
- except (TypeError, ValueError):
- volume = None
- results.append(
- MetadataResult(
- media_type="audiobook",
- title=p.get("title", ""),
- authors=", ".join(a.get("name", "") for a in p.get("authors") or []),
- narrator=", ".join(n.get("name", "") for n in p.get("narrators") or []),
- series=series,
- volume=volume,
- external_id=asin,
- year=int(release[:4]) if release[:4].isdigit() else None,
- cover_url=next(iter(images.values()), ""),
- )
- )
+ results.append(_to_result(p))
return results
+
+
+def _series_asins(p: dict, series_id: str) -> dict | None:
+ for s in p.get("series") or []:
+ if s.get("asin") == series_id:
+ return s
+ return None
+
+
+async def series_episodes(series_id: str, series_title: str) -> list[MetadataResult]:
+ """Every episode of the given series that the keyword search can reach,
+ sorted by episode number. Episodes without a number are appended at the end."""
+ queries = [f"{series_title}{suffix}" for suffix in SERIES_QUERY_SUFFIXES]
+ if "???" in series_title:
+ queries.insert(1, series_title.replace("???", "Fragezeichen"))
+
+ by_seq: dict[int, MetadataResult] = {}
+ extra: list[MetadataResult] = []
+ seen_asins: set[str] = set()
+ calls = 0
+
+ async with httpx.AsyncClient(timeout=25) as client:
+ for tld in REGIONS:
+ for query in queries:
+ if calls >= SERIES_MAX_CALLS:
+ break
+ pages = min(SERIES_MAX_PAGES, SERIES_MAX_CALLS - calls)
+ for start in range(1, pages + 1, SERIES_CHUNK):
+ chunk = range(start, min(start + SERIES_CHUNK, pages + 1))
+ calls += len(chunk)
+ results = await asyncio.gather(*(
+ _query(client, tld, query, num_results=PAGE_SIZE, page=page, sort=None)
+ for page in chunk
+ ))
+ if not any(results):
+ break # past the last page of this query
+ for products in results:
+ for p in products:
+ asin = p.get("asin", "")
+ if asin in seen_asins or not _series_asins(p, series_id):
+ continue
+ seen_asins.add(asin)
+ res = _to_result(p)
+ if res.volume is None:
+ extra.append(res)
+ else:
+ by_seq.setdefault(res.volume, res)
+ if by_seq or extra:
+ break # the asin is marketplace specific, one region owns it
+
+ return [by_seq[k] for k in sorted(by_seq)] + extra
diff --git a/wordarr/metadata/base.py b/wordarr/metadata/base.py
index 50d5c33..2691cd8 100644
--- a/wordarr/metadata/base.py
+++ b/wordarr/metadata/base.py
@@ -9,6 +9,7 @@ class MetadataResult(BaseModel):
external_id: str = ""
year: int | None = None
series: str = ""
+ series_id: str = ""
volume: int | None = None
cover_url: str = ""
description: str = ""