fix(import): re-offer imported requests whose files left the library
This commit is contained in:
@@ -7,7 +7,7 @@ Sonarr/Radarr-Style Request- & Import-Manager für **Ebooks**, **Comics/Mangas**
|
||||
1. **Anfragen**: Im Web-UI per Titel/Autor/ISBN suchen (Ebooks: Open Library · Audiobooks: Audible · Manga: AniList) oder manuell anlegen. Beim Request wählst du die **Ziel-Library** (z.B. deine 8 Bookorbit-Libraries oder die 3 Audiobookshelf-Libraries english/adult/kids). Der Titel erscheint als **Missing**.
|
||||
Gehört ein Audiobook-Treffer zu einer Serie, legt **„Ganze Serie…"** alle Folgen auf einmal an (Folgennummer + echter Titel von Audible, optional auf einen Folgenbereich eingegrenzt). Audible hat keine Serien-Abfrage — wordarr sammelt die Folgen über mehrere Suchläufe ein; sollte eine fehlen, zeigt der Dialog die Lücke an.
|
||||
2. **Download-Ordner**: wordarr überwacht keinen Downloader aktiv — du legst Dateien selbst in den Download-Ordner (`/mnt/downloads`).
|
||||
3. **Import missing**: Im Tab *Import* den Ordner scannen. wordarr schlägt per Fuzzy-Matching Datei→Request-Zuordnungen vor (bei Serien zählt die Folgennummer im Ordnernamen plus der Serienname im Pfad, `???` und `Fragezeichen` gelten als dasselbe); du bestätigst oder korrigierst. Über 🔍 lässt sich zu einem Ordner direkt eine Anfrage suchen — eine führende Folgennummer (`017 - Titel`) wird dabei aus der Suchanfrage genommen, weil Audible sonst schlechter trifft, und stattdessen als Band/Folge übernommen. Was Audible gar nicht führt (Live-Hörspiele, Fan-Editionen), legst du im selben Dialog unter *Nicht bei Audible? Manuell anlegen* an — Titel und Folge sind aus dem Ordnernamen vorbelegt, die Serie bleibt für den nächsten Eintrag stehen. Beim Import wird die Datei nach dem Namensschema der Library **umbenannt und verschoben**. Ordner mit mehreren Audio-Dateien werden als ein Audiobook behandelt (`Titel - Part 01.mp3`, …). Enthält ein Ordner dagegen **je Datei ein eigenes Buch** (eine Reihe als Einzeltitel gekauft), teilt ✂️ ihn in einzelne Einträge auf; wordarr markiert solche Ordner mit `n Titel?`, wenn die Dateinamen verschiedene Titel tragen und jede Datei groß genug für ein ganzes Buch ist. Die Checkbox *Ordner als Einzeldateien behandeln* macht dasselbe für den gesamten Scan. Auch Ordner, die in Disc-Unterordner (`CD`, `CD1`, `CD 2`, `Disc 3`, `Teil 1`) aufgeteilt sind, gelten als **ein** Audiobook — der Ordnername darüber liefert den Titel, die Parts werden über alle Discs hinweg durchnummeriert. Unterordner ohne Audio (`Cover`, Scans, Booklets) werden dabei ignoriert. Mehrteiler in Unterordnern (`Teil A`/`Teil B`, `A - Titel`/`B - Titel`) zählen ebenfalls als ein Hörbuch. Liegen die Teile dagegen **nebeneinander** (`100 - Toteninsel Teil 1`, `… Teil 2`, …), markierst du sie und klickst *Ausgewählte zusammenfassen* — sie werden als ein Hörbuch mit durchlaufenden Parts importiert (✂️ löst das wieder auf). Sind mehrere Einträge derselben Anfrage zugeordnet, fragt wordarr vor dem Import nach und fasst sie zusammen — sonst importiert nur der erste und der Rest scheitert. Fehlt einem bereits importierten Hörbuch später ein Teil, wählst du es im Zuordnungs-Dropdown unter *Bereits importiert — Teile anhängen* (↩︎); die neuen Dateien werden hinten angehängt und alle Tracks neu getaggt.
|
||||
3. **Import missing**: Im Tab *Import* den Ordner scannen. wordarr schlägt per Fuzzy-Matching Datei→Request-Zuordnungen vor (bei Serien zählt die Folgennummer im Ordnernamen plus der Serienname im Pfad, `???` und `Fragezeichen` gelten als dasselbe); du bestätigst oder korrigierst. Über 🔍 lässt sich zu einem Ordner direkt eine Anfrage suchen — eine führende Folgennummer (`017 - Titel`) wird dabei aus der Suchanfrage genommen, weil Audible sonst schlechter trifft, und stattdessen als Band/Folge übernommen. Was Audible gar nicht führt (Live-Hörspiele, Fan-Editionen), legst du im selben Dialog unter *Nicht bei Audible? Manuell anlegen* an — Titel und Folge sind aus dem Ordnernamen vorbelegt, die Serie bleibt für den nächsten Eintrag stehen. Beim Import wird die Datei nach dem Namensschema der Library **umbenannt und verschoben**. Ordner mit mehreren Audio-Dateien werden als ein Audiobook behandelt (`Titel - Part 01.mp3`, …). Enthält ein Ordner dagegen **je Datei ein eigenes Buch** (eine Reihe als Einzeltitel gekauft), teilt ✂️ ihn in einzelne Einträge auf; wordarr markiert solche Ordner mit `n Titel?`, wenn die Dateinamen verschiedene Titel tragen und jede Datei groß genug für ein ganzes Buch ist. Die Checkbox *Ordner als Einzeldateien behandeln* macht dasselbe für den gesamten Scan. Auch Ordner, die in Disc-Unterordner (`CD`, `CD1`, `CD 2`, `Disc 3`, `Teil 1`) aufgeteilt sind, gelten als **ein** Audiobook — der Ordnername darüber liefert den Titel, die Parts werden über alle Discs hinweg durchnummeriert. Unterordner ohne Audio (`Cover`, Scans, Booklets) werden dabei ignoriert. Mehrteiler in Unterordnern (`Teil A`/`Teil B`, `A - Titel`/`B - Titel`) zählen ebenfalls als ein Hörbuch. Liegen die Teile dagegen **nebeneinander** (`100 - Toteninsel Teil 1`, `… Teil 2`, …), markierst du sie und klickst *Ausgewählte zusammenfassen* — sie werden als ein Hörbuch mit durchlaufenden Parts importiert (✂️ löst das wieder auf). Liegen die Dateien einer bereits importierten Anfrage nicht mehr in der Library (zurück in den Download-Ordner verschoben, um sie nach einem neuen Namensschema abzulegen), gilt sie wieder als offen: der Scan schlägt sie ganz normal vor (mit `↺` markiert) und der Import legt sie neu ab. Sind mehrere Einträge derselben Anfrage zugeordnet, fragt wordarr vor dem Import nach und fasst sie zusammen — sonst importiert nur der erste und der Rest scheitert. Fehlt einem bereits importierten Hörbuch später ein Teil, wählst du es im Zuordnungs-Dropdown unter *Bereits importiert — Teile anhängen* (↩︎); die neuen Dateien werden hinten angehängt und alle Tracks neu getaggt.
|
||||
|
||||
## Setup (Docker)
|
||||
|
||||
|
||||
+7
-4
@@ -649,6 +649,7 @@ $("#detail-form").addEventListener("submit", async (e) => {
|
||||
let scanItems = [];
|
||||
let missingReqs = [];
|
||||
let importedReqs = [];
|
||||
let orphanedIds = new Set(); // imported, but their files are gone from the library
|
||||
let viewIdx = []; // indices into scanItems after filter/sort
|
||||
let importPage = 0;
|
||||
const IMPORT_PAGE_SIZE = 25;
|
||||
@@ -670,8 +671,10 @@ $("#scan-btn").addEventListener("click", async () => {
|
||||
item.request_id = item.suggested_request_id;
|
||||
item.checked = !!item.suggested_request_id;
|
||||
});
|
||||
missingReqs = reqs;
|
||||
importedReqs = done;
|
||||
orphanedIds = new Set(scan.orphaned_request_ids || []);
|
||||
// an imported title whose files vanished is selectable like an open one
|
||||
missingReqs = [...reqs, ...done.filter((r) => orphanedIds.has(r.id))];
|
||||
importedReqs = done.filter((r) => !orphanedIds.has(r.id));
|
||||
importPage = 0;
|
||||
$("#scan-info").textContent = `${scan.items.length} Kandidat(en) in ${scan.download_dir}`;
|
||||
$("#import-toolbar").hidden = scanItems.length === 0;
|
||||
@@ -734,7 +737,7 @@ function renderImportTable() {
|
||||
${esc(r.title)} — ${esc(r.authors)} (${esc(r.library_name)})${suffix}</option>`;
|
||||
const opts = missingReqs
|
||||
.filter((r) => r.media_type === item.media_type)
|
||||
.map((r) => option(r))
|
||||
.map((r) => option(r, orphanedIds.has(r.id) ? " ↺" : ""))
|
||||
.join("");
|
||||
// already imported audiobooks can take further parts (a late CD, or one
|
||||
// that failed while its siblings went through)
|
||||
@@ -1151,7 +1154,7 @@ function collapseSharedRequests(chosen) {
|
||||
is_dir: group.length > 1 ? true : group[0].is_dir,
|
||||
files: group.flatMap((it) => it.files),
|
||||
request_id: group[0].request_id,
|
||||
append: importedReqs.some((r) => r.id === group[0].request_id),
|
||||
append: importedReqs.some((r) => r.id === group[0].request_id), // orphans re-import
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import importlib
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -820,3 +821,71 @@ def test_relocate_refuses_to_overwrite(client):
|
||||
assert res.status_code == 409
|
||||
assert (occupied / "fremd.mp3").exists()
|
||||
assert (root / "Erstes" / "Erstes - Part 01.mp3").exists()
|
||||
|
||||
|
||||
def test_files_moved_back_are_matched_against_imported_requests(client):
|
||||
"""Moving a library folder back into the download dir: the request still says
|
||||
"imported", but its files are gone - the scan should suggest it anyway."""
|
||||
root = client.tmp_path / "library" / "kids"
|
||||
lib_id = client.post("/api/libraries", json={
|
||||
"name": "kids", "media_type": "audiobook", "root_path": str(root),
|
||||
"folder_template": "{Series}/{Volume} - {Title}", "file_template": "{Title}",
|
||||
}).json()["id"]
|
||||
req_id = client.post("/api/requests", json={
|
||||
"library_id": lib_id, "title": "Die Zeitreisende",
|
||||
"series": "Die drei ???", "volume": 194,
|
||||
}).json()["id"]
|
||||
|
||||
src = client.downloads / "194 - Die Zeitreisende"
|
||||
src.mkdir()
|
||||
for i in (1, 2):
|
||||
(src / f"{i:02d}.mp3").write_bytes(b"")
|
||||
dest = Path(client.post("/api/import", json={"items": [{
|
||||
"path": str(src), "is_dir": True,
|
||||
"files": [str(f) for f in sorted(src.iterdir())], "request_id": req_id,
|
||||
}]}).json()["results"][0]["dest"])
|
||||
|
||||
# while the files are in place the request is not offered again
|
||||
scan = client.get("/api/import/scan").json()
|
||||
assert scan["orphaned_request_ids"] == []
|
||||
|
||||
# now move the folder back into the download dir, as the user did
|
||||
shutil.move(str(dest), client.downloads / dest.name)
|
||||
scan = client.get("/api/import/scan").json()
|
||||
assert scan["orphaned_request_ids"] == [req_id]
|
||||
item = next(i for i in scan["items"] if i["name"] == dest.name)
|
||||
assert item["suggested_request_id"] == req_id
|
||||
|
||||
res = client.post("/api/import", json={"items": [{
|
||||
"path": item["path"], "is_dir": True, "files": item["files"],
|
||||
"request_id": req_id,
|
||||
}]}).json()["results"][0]
|
||||
assert res["ok"], res
|
||||
assert Path(res["dest"]) == root / "Die drei ???" / "194 - Die Zeitreisende"
|
||||
assert sorted(p.name for p in Path(res["dest"]).iterdir()) == [
|
||||
"Die Zeitreisende - Part 01.mp3", "Die Zeitreisende - Part 02.mp3",
|
||||
]
|
||||
|
||||
|
||||
def test_imported_request_with_files_in_place_is_still_refused(client):
|
||||
lib_id = client.post("/api/libraries", json={
|
||||
"name": "L", "media_type": "audiobook", "root_path": str(client.tmp_path / "l"),
|
||||
}).json()["id"]
|
||||
req_id = client.post("/api/requests",
|
||||
json={"library_id": lib_id, "title": "X"}).json()["id"]
|
||||
src = client.downloads / "a"
|
||||
src.mkdir()
|
||||
(src / "1.mp3").write_bytes(b"")
|
||||
(src / "2.mp3").write_bytes(b"")
|
||||
client.post("/api/import", json={"items": [{
|
||||
"path": str(src), "is_dir": True,
|
||||
"files": [str(src / "1.mp3"), str(src / "2.mp3")], "request_id": req_id,
|
||||
}]})
|
||||
again = client.downloads / "b"
|
||||
again.mkdir()
|
||||
(again / "1.mp3").write_bytes(b"")
|
||||
res = client.post("/api/import", json={"items": [{
|
||||
"path": str(again), "is_dir": False, "files": [str(again / "1.mp3")],
|
||||
"request_id": req_id,
|
||||
}]}).json()["results"][0]
|
||||
assert not res["ok"] and "bereits importiert" in res["error"]
|
||||
|
||||
+21
-3
@@ -12,6 +12,17 @@ from ..importer import matcher, mover, scanner
|
||||
router = APIRouter(prefix="/api/import", tags=["import"])
|
||||
|
||||
|
||||
def _orphaned(session: Session) -> list[BookRequest]:
|
||||
"""Imported requests whose files are gone from the library - moved away or
|
||||
deleted by hand. They are effectively missing again, so the scan should
|
||||
suggest them just like an open request."""
|
||||
imported = session.scalars(
|
||||
select(BookRequest).where(BookRequest.status == "imported")
|
||||
).all()
|
||||
return [r for r in imported
|
||||
if not r.imported_path or not Path(r.imported_path).exists()]
|
||||
|
||||
|
||||
@router.get("/scan")
|
||||
def scan(split_dirs: bool = False, session: Session = Depends(get_session)):
|
||||
"""Scan the download dir and suggest matches against open requests."""
|
||||
@@ -19,9 +30,12 @@ def scan(split_dirs: bool = False, session: Session = Depends(get_session)):
|
||||
missing = session.scalars(
|
||||
select(BookRequest).where(BookRequest.status == "missing")
|
||||
).all()
|
||||
orphaned = _orphaned(session)
|
||||
return {
|
||||
"download_dir": str(config.DOWNLOAD_DIR),
|
||||
"items": matcher.best_matches(items, missing),
|
||||
"items": matcher.best_matches(items, missing + orphaned),
|
||||
# the UI offers these like open requests instead of "append parts"
|
||||
"orphaned_request_ids": [r.id for r in orphaned],
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +62,11 @@ def do_import(data: ImportIn, session: Session = Depends(get_session)):
|
||||
results.append({"path": item.path, "ok": False,
|
||||
"error": f"Anfrage #{item.request_id} existiert nicht mehr"})
|
||||
continue
|
||||
if item.append and req.status == "imported":
|
||||
path_gone = not req.imported_path or not Path(req.imported_path).exists()
|
||||
if req.status == "imported" and path_gone:
|
||||
# the library copy is gone; treat this as a fresh import
|
||||
pass
|
||||
elif item.append and req.status == "imported":
|
||||
try:
|
||||
dest = mover.append_to_import(item.files, req, req.library)
|
||||
except Exception as exc:
|
||||
@@ -58,7 +76,7 @@ def do_import(data: ImportIn, session: Session = Depends(get_session)):
|
||||
session.commit()
|
||||
results.append({"path": item.path, "ok": True, "dest": dest, "appended": True})
|
||||
continue
|
||||
if req.status != "missing":
|
||||
elif req.status != "missing":
|
||||
# usually several entries pointing at the same request: the first one
|
||||
# imported and flipped it, the rest land here
|
||||
results.append({"path": item.path, "ok": False, "error": (
|
||||
|
||||
Reference in New Issue
Block a user