Feature/library adopt #5
No files matched your search
@@ -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
|
||||
+83
-2
@@ -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}
|
||||
Reference in new issue
Block a user