fix(matcher): use folder path as series context

This commit is contained in:
Steppenstreuner
2026-08-28 20:01:15 +02:00
parent b8f042025d
commit 0844d303db
12 changed files with 475 additions and 47 deletions
+2 -1
View File
@@ -5,8 +5,9 @@ Sonarr/Radarr-Style Request- & Import-Manager für **Ebooks**, **Comics/Mangas**
## Funktionsweise ## 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**. 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`). 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) ## Setup (Docker)
+117
View File
@@ -219,11 +219,15 @@ $("#search-form").addEventListener("submit", async (e) => {
<div class="row"> <div class="row">
<select data-lib="${i}">${opts || "<option value=''>— keine Library —</option>"}</select> <select data-lib="${i}">${opts || "<option value=''>— keine Library —</option>"}</select>
<button data-req="${i}">Anfragen</button> <button data-req="${i}">Anfragen</button>
${r.series_id ? `<button class="secondary" data-series="${i}">Ganze Serie…</button>` : ""}
</div> </div>
</div> </div>
</div>` </div>`
) )
.join(""); .join("");
box.querySelectorAll("[data-series]").forEach((btn) =>
btn.addEventListener("click", () => openSeriesDialog(results[btn.dataset.series]))
);
box.querySelectorAll("[data-req]").forEach((btn) => box.querySelectorAll("[data-req]").forEach((btn) =>
btn.addEventListener("click", async () => { btn.addEventListener("click", async () => {
const i = btn.dataset.req; 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 = `<div class="gap">… ${missing} Folge${missing > 1 ? "n" : ""} nicht bei Audible gefunden (${previous + 1}${e.volume - 1})</div>`;
}
if (e.volume != null) previous = e.volume;
return `${gap}<label>
<input type="checkbox" data-ep="${i}" ${inRange(e) ? "checked" : ""}>
<span class="num">${e.volume ?? "—"}</span>
<span>${esc(e.title)}</span>
</label>`;
})
.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 = '<span class="spinner"></span> Folgen werden gesucht…';
$("#series-list").innerHTML = "";
$("#series-from").value = "";
$("#series-to").value = "";
$("#series-library").innerHTML = libOptions("audiobook") || "<option value=''>— keine Library —</option>";
$("#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 = '<span class="spinner"></span> 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 request ----
$("#manual-btn").addEventListener("click", () => { $("#manual-btn").addEventListener("click", () => {
const dlg = $("#manual-dialog"); const dlg = $("#manual-dialog");
+19
View File
@@ -73,6 +73,25 @@
</div> </div>
</form> </form>
</dialog> </dialog>
<dialog id="series-dialog">
<h3 id="series-title">Serie anfragen</h3>
<p class="muted" id="series-info"></p>
<div class="bar wrap">
<label>von Folge <input id="series-from" type="number" min="0" /></label>
<label>bis Folge <input id="series-to" type="number" min="0" /></label>
<button type="button" id="series-all" class="secondary">Alle</button>
<button type="button" id="series-none" class="secondary">Keine</button>
</div>
<div id="series-list" class="series-list"></div>
<div class="row">
<label>Library <select id="series-library"></select></label>
</div>
<div class="row">
<button type="button" id="series-cancel" class="secondary">Abbrechen</button>
<button type="button" id="series-submit">Anfragen</button>
</div>
</dialog>
</section> </section>
<section id="view-missing" hidden> <section id="view-missing" hidden>
+8
View File
@@ -97,6 +97,13 @@ dialog label { display: flex; flex-direction: column; gap: 0.2rem; margin: 0.5re
} }
@keyframes spin { to { transform: rotate(360deg); } } @keyframes spin { to { transform: rotate(360deg); } }
#quick-dialog { min-width: 480px; } #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; } #quick-results { display: flex; flex-direction: column; max-height: 50vh; overflow-y: auto; margin-top: 0.6rem; }
.ok { color: var(--ok); } .ok { color: var(--ok); }
.error { color: #e08585; } .error { color: #e08585; }
@@ -110,5 +117,6 @@ h3, h4 { margin: 0.8rem 0 0.6rem; }
main { padding: 0.8rem; } main { padding: 0.8rem; }
.cards { grid-template-columns: 1fr; } .cards { grid-template-columns: 1fr; }
#quick-dialog { min-width: 0; width: 92vw; } #quick-dialog { min-width: 0; width: 92vw; }
#series-dialog { min-width: 0; width: 92vw; }
td select { max-width: 200px; } td select { max-width: 200px; }
} }
+103
View File
@@ -311,3 +311,106 @@ def test_natural_track_order_in_flat_folder(client):
(folder / f"track{i}.mp3").write_bytes(b"") (folder / f"track{i}.mp3").write_bytes(b"")
item = client.get("/api/import/scan").json()["items"][0] item = client.get("/api/import/scan").json()["items"][0]
assert [Path(f).name for f in item["files"]] == ["track1.mp3", "track2.mp3", "track10.mp3"] 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/<Sammelordner>/<NNN - Titel>/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
+26 -1
View File
@@ -1,6 +1,6 @@
from types import SimpleNamespace 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"): 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 = req(2, "Die dr3i - Folge 3", media_type="audiobook")
r3.volume = 3 r3.volume = 3
assert score("DiE DR3i - 05 - Der Fall", r) > score("DiE DR3i - 05 - Der Fall", r3) 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
+52 -2
View File
@@ -12,8 +12,7 @@ from ..importer import tagger
router = APIRouter(prefix="/api/requests", tags=["requests"]) router = APIRouter(prefix="/api/requests", tags=["requests"])
class RequestIn(BaseModel): class BulkItem(BaseModel):
library_id: int
title: str title: str
authors: str = "" authors: str = ""
narrator: str = "" narrator: str = ""
@@ -24,6 +23,10 @@ class RequestIn(BaseModel):
cover_url: str = "" cover_url: str = ""
class RequestIn(BulkItem):
library_id: int
class RequestOut(RequestIn): class RequestOut(RequestIn):
id: int id: int
media_type: str media_type: str
@@ -127,6 +130,53 @@ def create_bulk(data: BulkRequestIn, session: Session = Depends(get_session)):
return [_out(r) for r in reqs] 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) @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)
+14 -1
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from ..metadata import PROVIDERS from ..metadata import PROVIDERS, audible
from ..metadata.base import MetadataResult from ..metadata.base import MetadataResult
router = APIRouter(prefix="/api/search", tags=["search"]) router = APIRouter(prefix="/api/search", tags=["search"])
@@ -15,3 +15,16 @@ async def search(media_type: str, q: str):
return await provider(q) return await provider(q)
except Exception as exc: except Exception as exc:
raise HTTPException(502, f"metadata provider error: {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}")
+14 -3
View File
@@ -16,8 +16,12 @@ def _deleet(s: str) -> str:
def normalize(s: str) -> str: def normalize(s: str) -> str:
s = s.lower() 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) 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 = _deleet(s)
s = re.sub(r"\s+", " ", s) s = re.sub(r"\s+", " ", s)
return s.strip() 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))} 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}") target = normalize(f"{request.authors} {request.title}")
name = normalize(item_name) name = normalize(item_name)
s = fuzz.token_set_ratio(name, target) s = fuzz.token_set_ratio(name, target)
@@ -47,6 +51,13 @@ def score(item_name: str, request) -> float:
result = min(100.0, result + 5) result = min(100.0, result + 5)
elif nums: elif nums:
result = min(result, 40.0) 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 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"]] candidates = [r for r in requests if r.media_type == item["media_type"]]
best, best_score = None, 0.0 best, best_score = None, 0.0
for r in candidates: for r in candidates:
sc = score(item["name"], r) sc = score(item["name"], r, item.get("rel_dir", ""))
if sc > best_score: if sc > best_score:
best, best_score = r, sc best, best_score = r, sc
out.append({ out.append({
+23 -12
View File
@@ -3,10 +3,11 @@ from pathlib import Path
from .. import config from .. import config
# "CD1", "CD 2", "Disc_03", "Teil 1", "Part 2" as folder name. A bare number is # "CD", "CD1", "CD 2", "Disc_03", "Teil 1", "Part 2" as folder name. The number is
# deliberately not a disc: those folders are usually episodes of a series. # 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( 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, re.IGNORECASE,
) )
@@ -28,8 +29,12 @@ def natural_key(name: str) -> list:
def disc_number(name: str) -> int | None: 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()) 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]: 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]: def subdirs(d: Path) -> list[Path]:
return [s for s in d.iterdir() if s.is_dir() and not s.name.startswith(".")] 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: def disc_audio(d: Path) -> list[Path] | None:
"""Audio files of d's disc subfolders (CD1, CD2, …), in disc order. """Audio files of d's disc subfolders ("CD", "CD1", "CD2", …), in disc
None if d is not a multi-disc folder.""" order. Subfolders without audio (Cover, Scans, …) are ignored. None if d
subs = subdirs(d) is not a disc-split folder."""
if not subs:
return None
discs = [] 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) num = disc_number(s.name)
if num is None or not audio_files(s): if num is None:
return None # not purely a disc split -> walk normally return None # a real subfolder -> not a disc split, walk normally
discs.append((num, s)) discs.append((num, s))
files = [] files = []
for _, s in sorted(discs, key=lambda t: (t[0], natural_key(t[1].name))): for _, s in sorted(discs, key=lambda t: (t[0], natural_key(t[1].name))):
+96 -27
View File
@@ -8,14 +8,30 @@ from .base import MetadataResult
# Comma-separated marketplace TLDs, first entries rank first in the results. # 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(",")] 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 = { params = {
"keywords": query, "keywords": query,
"num_results": 10, "num_results": num_results,
"response_groups": "media,contributors,product_desc,product_attrs,series", "page": page,
"products_sort_by": "Relevance", "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: try:
resp = await client.get(f"https://api.audible.{tld}/1.0/catalog/products", params=params) resp = await client.get(f"https://api.audible.{tld}/1.0/catalog/products", params=params)
resp.raise_for_status() resp.raise_for_status()
@@ -24,10 +40,33 @@ async def _search_region(client: httpx.AsyncClient, tld: str, query: str) -> lis
return [] 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 def search(query: str) -> list[MetadataResult]:
async with httpx.AsyncClient(timeout=15) as client: async with httpx.AsyncClient(timeout=15) as client:
region_results = await asyncio.gather( region_results = await asyncio.gather(
*(_search_region(client, tld, query) for tld in REGIONS) *(_query(client, tld, query) for tld in REGIONS)
) )
results = [] results = []
@@ -38,26 +77,56 @@ async def search(query: str) -> list[MetadataResult]:
if asin in seen_asins: if asin in seen_asins:
continue continue
seen_asins.add(asin) seen_asins.add(asin)
images = p.get("product_images") or {} results.append(_to_result(p))
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()), ""),
)
)
return results 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
+1
View File
@@ -9,6 +9,7 @@ class MetadataResult(BaseModel):
external_id: str = "" external_id: str = ""
year: int | None = None year: int | None = None
series: str = "" series: str = ""
series_id: str = ""
volume: int | None = None volume: int | None = None
cover_url: str = "" cover_url: str = ""
description: str = "" description: str = ""