diff --git a/static/app.js b/static/app.js index d5969ca..fef8863 100644 --- a/static/app.js +++ b/static/app.js @@ -182,18 +182,36 @@ $("#manual-form").addEventListener("submit", async (e) => { const data = Object.fromEntries(new FormData(e.target)); if (!data.library_id) { toast("Keine Library gewählt", true); return; } try { - await api("/api/requests", { - method: "POST", - body: JSON.stringify({ - library_id: parseInt(data.library_id), - title: data.title, authors: data.authors, series: data.series, - external_id: data.external_id, - year: data.year ? parseInt(data.year) : null, - volume: data.volume ? parseInt(data.volume) : null, - }), - }); + if (data.volume_to) { + if (!data.volume) { + toast("Für Bulk bitte Start-Band angeben", true); + return; + } + const created = await api("/api/requests/bulk", { + method: "POST", + body: JSON.stringify({ + library_id: parseInt(data.library_id), + series: data.series || data.title, authors: data.authors, + volume_from: parseInt(data.volume), + volume_to: parseInt(data.volume_to), + year: data.year ? parseInt(data.year) : null, + }), + }); + toast(`${created.length} Anfragen angelegt`); + } else { + await api("/api/requests", { + method: "POST", + body: JSON.stringify({ + library_id: parseInt(data.library_id), + title: data.title, authors: data.authors, series: data.series, + external_id: data.external_id, + year: data.year ? parseInt(data.year) : null, + volume: data.volume ? parseInt(data.volume) : null, + }), + }); + toast("Anfrage angelegt"); + } e.target.reset(); - toast("Anfrage angelegt"); } catch (err) { toast(err.message, true); } }); @@ -241,8 +259,9 @@ let missingReqs = []; $("#scan-btn").addEventListener("click", async () => { $("#scan-info").textContent = "Scanne…"; try { + const split = $("#split-dirs").checked; const [scan, reqs] = await Promise.all([ - api("/api/import/scan"), + api("/api/import/scan?split_dirs=" + split), api("/api/requests?status=missing"), ]); scanItems = scan.items; diff --git a/static/index.html b/static/index.html index e6462f1..57190c3 100644 --- a/static/index.html +++ b/static/index.html @@ -48,6 +48,13 @@ Autor(en) Serie Band + bis Band (Bulk, optional) + + Legt pro Band/Folge eine Anfrage an, z.B. 1 bis 300 + Jahr ISBN/ID Download-Ordner scannen + Ordner als Einzeldateien + behandeln (z.B. Folgen-Sammlungen) diff --git a/tests/test_import_flow.py b/tests/test_import_flow.py index 09d5c0d..e07b010 100644 --- a/tests/test_import_flow.py +++ b/tests/test_import_flow.py @@ -106,3 +106,31 @@ def test_import_rejects_path_outside_download_dir(client): "path": str(outside), "is_dir": False, "files": [str(outside)], "request_id": req_id, }]}) assert resp.json()["results"][0]["ok"] is False + + +def test_bulk_requests_and_split_scan(client): + root = client.tmp_path / "library" / "audio" + lib_id = client.post("/api/libraries", json={ + "name": "Hoerspiele", "media_type": "audiobook", "root_path": str(root), + }).json()["id"] + resp = client.post("/api/requests/bulk", json={ + "library_id": lib_id, "series": "Die drei ???", "volume_from": 1, "volume_to": 3, + }) + assert resp.status_code == 200 + created = resp.json() + assert [r["title"] for r in created] == [ + "Die drei ??? Folge 01", "Die drei ??? Folge 02", "Die drei ??? Folge 03", + ] + + folder = client.downloads / "Die drei Fragezeichen Sammlung" + folder.mkdir() + for n in (1, 2, 3): + (folder / f"Die drei Fragezeichen - Folge {n:03d}.mp3").write_bytes(b"a") + + # default scan: one audiobook unit; split scan: three individual files + assert len(client.get("/api/import/scan").json()["items"]) == 1 + items = client.get("/api/import/scan", params={"split_dirs": "true"}).json()["items"] + assert len(items) == 3 + by_name = {i["name"]: i["suggested_request_id"] for i in items} + expected = {f"Die drei Fragezeichen - Folge {n:03d}": created[n - 1]["id"] for n in (1, 2, 3)} + assert by_name == expected diff --git a/tests/test_matcher.py b/tests/test_matcher.py index e1663b4..cfacaae 100644 --- a/tests/test_matcher.py +++ b/tests/test_matcher.py @@ -4,7 +4,7 @@ from wordarr.importer.matcher import best_matches, normalize def req(id, title, authors="", media_type="ebook"): - return SimpleNamespace(id=id, title=title, authors=authors, media_type=media_type) + return SimpleNamespace(id=id, title=title, authors=authors, media_type=media_type, volume=None) def test_normalize(): @@ -36,3 +36,14 @@ def test_media_type_filter(): "is_dir": False, "files": ["/d/dune.epub"]}] out = best_matches(items, requests) assert out[0]["suggested_request_id"] is None + + +def test_volume_number_must_match(): + r1 = req(1, "Die drei ??? Folge 001", "", media_type="audiobook") + r1.volume = 1 + r123 = req(2, "Die drei ??? Folge 123", "", media_type="audiobook") + r123.volume = 123 + items = [{"path": "/d/f.mp3", "name": "Die drei Fragezeichen - Folge 123 - Der Superpapagei", + "media_type": "audiobook", "is_dir": False, "files": ["/d/f.mp3"]}] + out = best_matches(items, [r1, r123]) + assert out[0]["suggested_request_id"] == 2 diff --git a/wordarr/api/imports.py b/wordarr/api/imports.py index e02d4e4..eb54ead 100644 --- a/wordarr/api/imports.py +++ b/wordarr/api/imports.py @@ -13,9 +13,9 @@ router = APIRouter(prefix="/api/import", tags=["import"]) @router.get("/scan") -def scan(session: Session = Depends(get_session)): +def scan(split_dirs: bool = False, session: Session = Depends(get_session)): """Scan the download dir and suggest matches against open requests.""" - items = scanner.scan(Path(config.DOWNLOAD_DIR)) + items = scanner.scan(Path(config.DOWNLOAD_DIR), split_dirs=split_dirs) missing = session.scalars( select(BookRequest).where(BookRequest.status == "missing") ).all() diff --git a/wordarr/api/requests.py b/wordarr/api/requests.py index 883351c..d5f1409 100644 --- a/wordarr/api/requests.py +++ b/wordarr/api/requests.py @@ -61,6 +61,46 @@ def create_request(data: RequestIn, session: Session = Depends(get_session)): return _out(req) +class BulkRequestIn(BaseModel): + library_id: int + series: str + authors: str = "" + volume_from: int + volume_to: int + year: int | None = None + cover_url: str = "" + + +@router.post("/bulk", response_model=list[RequestOut]) +def create_bulk(data: BulkRequestIn, session: Session = Depends(get_session)): + lib = session.get(Library, data.library_id) + if not lib: + raise HTTPException(404, "library not found") + if data.volume_from > data.volume_to: + raise HTTPException(400, "volume_from must be <= volume_to") + if data.volume_to - data.volume_from > 999: + raise HTTPException(400, "at most 1000 requests per bulk call") + width = max(2, len(str(data.volume_to))) + reqs = [] + for n in range(data.volume_from, data.volume_to + 1): + req = BookRequest( + media_type=lib.media_type, + library_id=lib.id, + title=f"{data.series} Folge {n:0{width}d}", + authors=data.authors, + series=data.series, + volume=n, + year=data.year, + cover_url=data.cover_url, + ) + session.add(req) + reqs.append(req) + session.commit() + for r in reqs: + session.refresh(r) + return [_out(r) for r in reqs] + + @router.delete("/{request_id}") def delete_request(request_id: int, session: Session = Depends(get_session)): req = session.get(BookRequest, request_id) diff --git a/wordarr/importer/matcher.py b/wordarr/importer/matcher.py index 8c824da..c640aee 100644 --- a/wordarr/importer/matcher.py +++ b/wordarr/importer/matcher.py @@ -11,12 +11,25 @@ def normalize(s: str) -> str: return s.strip() +def _numbers(s: str) -> set[int]: + return {int(n) for n in re.findall(r"\d+", s)} + + def score(item_name: str, request) -> float: target = normalize(f"{request.authors} {request.title}") name = normalize(item_name) s = fuzz.token_set_ratio(name, target) title_only = fuzz.token_set_ratio(name, normalize(request.title)) - return max(s, title_only * 0.95) + result = max(s, title_only * 0.95) + # for numbered volumes/episodes the number must match, otherwise every + # episode of a series scores alike on the shared title tokens + if request.volume is not None: + nums = _numbers(item_name) + if request.volume in nums: + result = min(100.0, result + 5) + elif nums: + result = min(result, 40.0) + return result def best_matches(items: list[dict], requests) -> list[dict]: diff --git a/wordarr/importer/scanner.py b/wordarr/importer/scanner.py index d2250ed..37e9cf8 100644 --- a/wordarr/importer/scanner.py +++ b/wordarr/importer/scanner.py @@ -14,9 +14,11 @@ def media_type_for(ext: str) -> str | None: return None -def scan(root: Path) -> list[dict]: +def scan(root: Path, split_dirs: bool = False) -> list[dict]: """Scan the download dir. Returns items: single files, or a directory that is - an audiobook unit (contains >1 audio file and nothing but audio/junk).""" + an audiobook unit (contains >1 audio file and nothing but audio/junk). + With split_dirs=True every file is listed individually (e.g. for folders + holding many episodes of a series).""" items = [] if not root.is_dir(): return items @@ -29,7 +31,7 @@ def scan(root: Path) -> list[dict]: def walk(d: Path): audio = audio_files(d) - if len(audio) > 1: + if len(audio) > 1 and not split_dirs: items.append({ "path": str(d), "name": d.name,