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}