From 4f3160d5e712f2067eae792131e71011a03829d0 Mon Sep 17 00:00:00 2001 From: Steppenstreuner Date: Sat, 29 Aug 2026 08:10:30 +0200 Subject: [PATCH] fix(import): never overwrite an existing file when importing a folder --- README.md | 2 +- tests/test_import_flow.py | 39 +++++++++++++++++++++++++++++++++++++++ wordarr/importer/mover.py | 17 +++++++++++++++-- 3 files changed, 55 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 830e06f..a92ef13 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ docker compose up -d --build Audible führt Reihen manchmal anders, als man sie ablegen möchte (*Harry Potter* steht dort unter *Wizarding World*, und das landet über `{Series}` im Ordnernamen). Deshalb: - **Vor dem Anlegen**: Im Import-Dialog übernimmt ✎ an einem Treffer dessen Felder (Titel, Autor, Serie, Band) in die Eingabemaske — Sprecher, Cover, Jahr und ASIN bleiben erhalten. Erst *Anlegen & verbinden* erzeugt die Anfrage. -- **Danach**: Im Tab *Missing* bzw. *Importiert* öffnet ein Klick auf die Karte alle Felder. *Speichern & neu taggen* schreibt die ID3-Tags neu, **Speichern & neu ablegen** wendet das Namensschema der Library auf die bereits verschobenen Dateien an — der Ordner wird also umbenannt bzw. verschoben, Cover ziehen mit um und leer gewordene Ordner verschwinden. Ein belegter Zielordner bricht den Vorgang ab, statt etwas zu überschreiben. +- **Danach**: Im Tab *Missing* bzw. *Importiert* öffnet ein Klick auf die Karte alle Felder. *Speichern & neu taggen* schreibt die ID3-Tags neu, **Speichern & neu ablegen** wendet das Namensschema der Library auf die bereits verschobenen Dateien an — der Ordner wird also umbenannt bzw. verschoben, Cover ziehen mit um und leer gewordene Ordner verschwinden. Ein belegter Zielordner bricht den Vorgang ab, statt etwas zu überschreiben — dasselbe gilt beim Import selbst: Rendern zwei Titel nach dem Namensschema denselben Pfad (etwa zwei Bände mit identischem Titel bei einem Schema ohne `{Volume}`), scheitert der zweite mit einer Meldung, statt den ersten zu überschreiben. ## Sprachen diff --git a/tests/test_import_flow.py b/tests/test_import_flow.py index 8936f4b..1c9993b 100644 --- a/tests/test_import_flow.py +++ b/tests/test_import_flow.py @@ -889,3 +889,42 @@ def test_imported_request_with_files_in_place_is_still_refused(client): "request_id": req_id, }]}).json()["results"][0] assert not res["ok"] and "bereits importiert" in res["error"] + + +def test_two_titles_rendering_to_the_same_name_do_not_overwrite(client): + """Two books whose templates produce the same folder and file names must not + silently replace each other - that loses files and confuses ABS.""" + root = client.tmp_path / "library" / "dcc" + lib_id = client.post("/api/libraries", json={ + "name": "DCC", "media_type": "audiobook", "root_path": str(root), + "folder_template": "{Series}", "file_template": "{Title}", + }).json()["id"] + ids = [client.post("/api/requests", json={ + "library_id": lib_id, "title": "Dungeon Crawler Carl", + "series": "Dungeon Crawler Carl", "volume": v, + }, params={"allow_duplicate": True}).json()["id"] for v in (1, 7)] + + dests = [] + for n, req_id in enumerate(ids, 1): + src = client.downloads / f"teil{n}" + src.mkdir() + for i in (1, 2): + (src / f"{i:02d}.mp3").write_bytes(f"Teil {n} Datei {i}".encode()) + res = 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] + dests.append(res) + + assert dests[0]["ok"] + assert not dests[1]["ok"] + assert "already exists" in dests[1]["error"] + # the first title is untouched and the second one still has its files + folder = root / "Dungeon Crawler Carl" + assert sorted(p.name for p in folder.iterdir()) == [ + "Dungeon Crawler Carl - Part 01.mp3", "Dungeon Crawler Carl - Part 02.mp3", + ] + content = (folder / "Dungeon Crawler Carl - Part 01.mp3").read_bytes() + assert b"Teil 1 Datei 1" in content and b"Teil 2" not in content + assert sorted(p.name for p in (client.downloads / "teil2").iterdir()) == \ + ["01.mp3", "02.mp3"] diff --git a/wordarr/importer/mover.py b/wordarr/importer/mover.py index c527dbb..b921227 100644 --- a/wordarr/importer/mover.py +++ b/wordarr/importer/mover.py @@ -38,8 +38,21 @@ def import_item(item_path: str, files: list[str], is_dir: bool, request, library paths = [Path(f) for f in files] if is_dir and len(paths) > 1: width = max(2, len(str(len(paths)))) - for i, src in enumerate(paths, 1): - dest = folder / sanitize(f"{base} - Part {i:0{width}d}{src.suffix.lower()}") + targets = [ + folder / sanitize(f"{base} - Part {i:0{width}d}{src.suffix.lower()}") + for i, src in enumerate(paths, 1) + ] + # check every target first: moving file by file would leave a half + # imported folder behind, and without the check a second title rendering + # to the same name would silently overwrite the first one + taken = [d for d in targets if d.exists()] + if taken: + raise FileExistsError( + f"Destination already exists: {taken[0]}" + + (f" (und {len(taken) - 1} weitere)" if len(taken) > 1 else "") + + " — trägt ein anderer Titel nach diesem Namensschema denselben Namen?" + ) + for i, (src, dest) in enumerate(zip(paths, targets), 1): shutil.move(str(src), dest) if request.media_type == "audiobook": tagger.tag_audio(dest, request, track=i, total=len(paths))