add(chore): bulk requests
This commit is contained in:
+31
-12
@@ -182,18 +182,36 @@ $("#manual-form").addEventListener("submit", async (e) => {
|
|||||||
const data = Object.fromEntries(new FormData(e.target));
|
const data = Object.fromEntries(new FormData(e.target));
|
||||||
if (!data.library_id) { toast("Keine Library gewählt", true); return; }
|
if (!data.library_id) { toast("Keine Library gewählt", true); return; }
|
||||||
try {
|
try {
|
||||||
await api("/api/requests", {
|
if (data.volume_to) {
|
||||||
method: "POST",
|
if (!data.volume) {
|
||||||
body: JSON.stringify({
|
toast("Für Bulk bitte Start-Band angeben", true);
|
||||||
library_id: parseInt(data.library_id),
|
return;
|
||||||
title: data.title, authors: data.authors, series: data.series,
|
}
|
||||||
external_id: data.external_id,
|
const created = await api("/api/requests/bulk", {
|
||||||
year: data.year ? parseInt(data.year) : null,
|
method: "POST",
|
||||||
volume: data.volume ? parseInt(data.volume) : null,
|
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();
|
e.target.reset();
|
||||||
toast("Anfrage angelegt");
|
|
||||||
} catch (err) { toast(err.message, true); }
|
} catch (err) { toast(err.message, true); }
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -241,8 +259,9 @@ let missingReqs = [];
|
|||||||
$("#scan-btn").addEventListener("click", async () => {
|
$("#scan-btn").addEventListener("click", async () => {
|
||||||
$("#scan-info").textContent = "Scanne…";
|
$("#scan-info").textContent = "Scanne…";
|
||||||
try {
|
try {
|
||||||
|
const split = $("#split-dirs").checked;
|
||||||
const [scan, reqs] = await Promise.all([
|
const [scan, reqs] = await Promise.all([
|
||||||
api("/api/import/scan"),
|
api("/api/import/scan?split_dirs=" + split),
|
||||||
api("/api/requests?status=missing"),
|
api("/api/requests?status=missing"),
|
||||||
]);
|
]);
|
||||||
scanItems = scan.items;
|
scanItems = scan.items;
|
||||||
|
|||||||
@@ -48,6 +48,13 @@
|
|||||||
<label>Autor(en) <input name="authors" /></label>
|
<label>Autor(en) <input name="authors" /></label>
|
||||||
<label>Serie <input name="series" /></label>
|
<label>Serie <input name="series" /></label>
|
||||||
<label>Band <input name="volume" type="number" min="0" /></label>
|
<label>Band <input name="volume" type="number" min="0" /></label>
|
||||||
|
<label
|
||||||
|
>bis Band (Bulk, optional)
|
||||||
|
<input name="volume_to" type="number" min="0" />
|
||||||
|
<span class="muted"
|
||||||
|
>Legt pro Band/Folge eine Anfrage an, z.B. 1 bis 300</span
|
||||||
|
>
|
||||||
|
</label>
|
||||||
<label>Jahr <input name="year" type="number" /></label>
|
<label>Jahr <input name="year" type="number" /></label>
|
||||||
<label>ISBN/ID <input name="external_id" /></label>
|
<label>ISBN/ID <input name="external_id" /></label>
|
||||||
<label
|
<label
|
||||||
@@ -84,6 +91,10 @@
|
|||||||
<section id="view-import" hidden>
|
<section id="view-import" hidden>
|
||||||
<div class="bar">
|
<div class="bar">
|
||||||
<button id="scan-btn">Download-Ordner scannen</button>
|
<button id="scan-btn">Download-Ordner scannen</button>
|
||||||
|
<label class="muted"
|
||||||
|
><input type="checkbox" id="split-dirs" /> Ordner als Einzeldateien
|
||||||
|
behandeln (z.B. Folgen-Sammlungen)</label
|
||||||
|
>
|
||||||
<span id="scan-info" class="muted"></span>
|
<span id="scan-info" class="muted"></span>
|
||||||
</div>
|
</div>
|
||||||
<table id="import-table" hidden>
|
<table id="import-table" hidden>
|
||||||
|
|||||||
@@ -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,
|
"path": str(outside), "is_dir": False, "files": [str(outside)], "request_id": req_id,
|
||||||
}]})
|
}]})
|
||||||
assert resp.json()["results"][0]["ok"] is False
|
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
|
||||||
|
|||||||
+12
-1
@@ -4,7 +4,7 @@ from wordarr.importer.matcher import best_matches, normalize
|
|||||||
|
|
||||||
|
|
||||||
def req(id, title, authors="", media_type="ebook"):
|
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():
|
def test_normalize():
|
||||||
@@ -36,3 +36,14 @@ def test_media_type_filter():
|
|||||||
"is_dir": False, "files": ["/d/dune.epub"]}]
|
"is_dir": False, "files": ["/d/dune.epub"]}]
|
||||||
out = best_matches(items, requests)
|
out = best_matches(items, requests)
|
||||||
assert out[0]["suggested_request_id"] is None
|
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
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ router = APIRouter(prefix="/api/import", tags=["import"])
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/scan")
|
@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."""
|
"""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(
|
missing = session.scalars(
|
||||||
select(BookRequest).where(BookRequest.status == "missing")
|
select(BookRequest).where(BookRequest.status == "missing")
|
||||||
).all()
|
).all()
|
||||||
|
|||||||
@@ -61,6 +61,46 @@ def create_request(data: RequestIn, session: Session = Depends(get_session)):
|
|||||||
return _out(req)
|
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}")
|
@router.delete("/{request_id}")
|
||||||
def delete_request(request_id: int, session: Session = Depends(get_session)):
|
def delete_request(request_id: int, session: Session = Depends(get_session)):
|
||||||
req = session.get(BookRequest, request_id)
|
req = session.get(BookRequest, request_id)
|
||||||
|
|||||||
@@ -11,12 +11,25 @@ def normalize(s: str) -> str:
|
|||||||
return s.strip()
|
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:
|
def score(item_name: str, request) -> 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)
|
||||||
title_only = fuzz.token_set_ratio(name, normalize(request.title))
|
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]:
|
def best_matches(items: list[dict], requests) -> list[dict]:
|
||||||
|
|||||||
@@ -14,9 +14,11 @@ def media_type_for(ext: str) -> str | None:
|
|||||||
return 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
|
"""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 = []
|
items = []
|
||||||
if not root.is_dir():
|
if not root.is_dir():
|
||||||
return items
|
return items
|
||||||
@@ -29,7 +31,7 @@ def scan(root: Path) -> list[dict]:
|
|||||||
|
|
||||||
def walk(d: Path):
|
def walk(d: Path):
|
||||||
audio = audio_files(d)
|
audio = audio_files(d)
|
||||||
if len(audio) > 1:
|
if len(audio) > 1 and not split_dirs:
|
||||||
items.append({
|
items.append({
|
||||||
"path": str(d),
|
"path": str(d),
|
||||||
"name": d.name,
|
"name": d.name,
|
||||||
|
|||||||
Reference in New Issue
Block a user