From 46c3c91a8dda41781707d2da6a820f83542dffd7 Mon Sep 17 00:00:00 2001 From: Steppenstreuner Date: Sun, 25 Oct 2026 19:55:05 +0100 Subject: [PATCH 1/2] feat(import): adopt files that are already in a library --- tests/test_import_flow.py | 34 ++++++++++++++++ wordarr/api/imports.py | 85 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 117 insertions(+), 2 deletions(-) diff --git a/tests/test_import_flow.py b/tests/test_import_flow.py index 74ba814..81296f5 100644 --- a/tests/test_import_flow.py +++ b/tests/test_import_flow.py @@ -1294,3 +1294,37 @@ def test_bulk_move_delete_and_count(client): 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 diff --git a/wordarr/api/imports.py b/wordarr/api/imports.py index c8a7b74..d3abdde 100644 --- a/wordarr/api/imports.py +++ b/wordarr/api/imports.py @@ -6,8 +6,8 @@ from sqlalchemy import select from sqlalchemy.orm import Session from .. import config, connect -from ..db import BookRequest, get_session, record -from ..fsnames import decode_path, readable +from ..db import BookRequest, Library, get_session, record +from ..fsnames import decode_path, is_broken, readable from ..importer import matcher, mover, scanner router = APIRouter(prefix="/api/import", tags=["import"]) @@ -128,3 +128,84 @@ def do_import(data: ImportIn, background: BackgroundTasks, 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} From d0b12d5d0974a49924b20ef4d2ee1c543cab84e1 Mon Sep 17 00:00:00 2001 From: Steppenstreuner Date: Sun, 25 Oct 2026 19:55:22 +0100 Subject: [PATCH 2/2] feat(ui): scan a library and link its files to open requests --- static/app.js | 76 ++++++++++++++++++++++++++++++++++++++++++++++- static/i18n.js | 16 ++++++++++ static/index.html | 10 +++++++ 3 files changed, 101 insertions(+), 1 deletion(-) diff --git a/static/app.js b/static/app.js index 7bcadde..3dbe386 100644 --- a/static/app.js +++ b/static/app.js @@ -126,6 +126,7 @@ async function loadLibraries() { ${l.language ? esc(languageName(l.language)) : "—"} + ` @@ -134,6 +135,9 @@ async function loadLibraries() { tbody.querySelectorAll("[data-edit-lib]").forEach((b) => b.addEventListener("click", () => startLibraryEdit(parseInt(b.dataset.editLib))) ); + tbody.querySelectorAll("[data-adopt-lib]").forEach((b) => + b.addEventListener("click", () => openAdopt(parseInt(b.dataset.adoptLib))) + ); tbody.querySelectorAll("[data-del-lib]").forEach((b) => b.addEventListener("click", async () => { if (!confirm(t("lib.confirmDelete"))) return; @@ -664,7 +668,7 @@ $("#missing-bulk-delete").addEventListener("click", () => bulkDeleteRequests("mi // ---- activity ---- let historyRows = []; -const HISTORY_STATE = { imported: "done", appended: "done", failed: "failed" }; +const HISTORY_STATE = { imported: "done", appended: "done", adopted: "done", failed: "failed" }; async function loadHistory() { try { @@ -1818,6 +1822,75 @@ function setImportProgress(done, total) { `${done}/${total} · ${Math.round((done / total) * 100)} %`; } +// ---- adopt files already in a library ---- +let adoptLib = null; +let adoptScan = null; + +async function openAdopt(id) { + adoptLib = libraries.find((l) => l.id === id); + adoptScan = null; + $("#adopt-heading").textContent = t("adopt.heading", { name: adoptLib.name }); + $("#adopt-intro").innerHTML = ` ${t("import.scanning")}`; + $("#adopt-list").innerHTML = ""; + $("#adopt-submit").disabled = true; + $("#adopt-dialog").showModal(); + try { + adoptScan = await api("/api/import/library/" + id); + } catch (err) { + $("#adopt-intro").textContent = ""; + toast(err.message, true); + return; + } + renderAdopt(); +} + +function renderAdopt() { + if (!adoptScan) return; + // only files with a suggestion: a select per unmatched file would bury them + const matched = adoptScan.items.filter((i) => i.suggested_request_id); + $("#adopt-intro").textContent = t("adopt.intro", { + n: matched.length, rest: adoptScan.items.length - matched.length, + }); + const options = adoptScan.requests + .map((r) => [r.id, r.authors ? `${r.authors}: ${r.title}` : r.title]); + $("#adopt-list").innerHTML = matched + .map((item) => ``) + .join(""); + $("#adopt-submit").disabled = matched.length === 0; +} + +$("#adopt-cancel").addEventListener("click", () => $("#adopt-dialog").close()); + +$("#adopt-submit").addEventListener("click", async () => { + const items = [...$$("#adopt-list [data-adopt-path]:checked")].map((box) => ({ + path: box.dataset.adoptPath, + request_id: parseInt(box.closest("label").querySelector("select").value), + })); + if (!items.length) return; + $("#adopt-submit").disabled = true; + try { + const { results } = await api("/api/import/library/" + adoptLib.id, { + method: "POST", body: JSON.stringify({ items }), + }); + const failed = results.filter((r) => !r.ok); + toast(failed.length + ? t("adopt.partial", { n: results.length - failed.length, failed: failed.map((r) => `${r.path}: ${r.error}`).join("; ") }) + : t("adopt.done", { n: results.length }), failed.length > 0); + $("#adopt-dialog").close(); + refreshMissingBadge(); + } catch (err) { + toast(err.message, true); + } finally { + $("#adopt-submit").disabled = false; + } +}); + // ---- language ---- function updateLangButton() { $("#lang-toggle").textContent = lang === "de" ? "EN" : "DE"; @@ -1836,6 +1909,7 @@ function onLangChange() { const editId = $("#lib-form").dataset.editId; editId ? startLibraryEdit(parseInt(editId)) : endLibraryEdit(); if (scanItems.length) renderImportTable(); + renderAdopt(); } // ---- version ---- diff --git a/static/i18n.js b/static/i18n.js index 745278b..14bb8dc 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -56,6 +56,14 @@ const STRINGS = { "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", @@ -296,6 +304,14 @@ const STRINGS = { "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", diff --git a/static/index.html b/static/index.html index 73331d6..b92f1ca 100644 --- a/static/index.html +++ b/static/index.html @@ -379,6 +379,16 @@ + +

+

+
+
+ + +
+
+