fix(matcher): use folder path as series context

This commit is contained in:
Steppenstreuner
2026-08-28 20:01:15 +02:00
parent b8f042025d
commit 0844d303db
12 changed files with 475 additions and 47 deletions
+103
View File
@@ -311,3 +311,106 @@ def test_natural_track_order_in_flat_folder(client):
(folder / f"track{i}.mp3").write_bytes(b"")
item = client.get("/api/import/scan").json()["items"][0]
assert [Path(f).name for f in item["files"]] == ["track1.mp3", "track2.mp3", "track10.mp3"]
def test_real_world_series_tree(client):
"""Downloads laid out as Serie/Folgen/<Sammelordner>/<NNN - Titel>/CD/*.mp3,
with a Cover/ folder next to the CD folder."""
base = client.downloads / "Die Drei Fragezeichen" / "Folgen" / "3478632869 001-010"
episodes = ["001 - Der Super - Papagei", "002 - Der Phantomsee", "003 - Der Karpatenhund"]
for ep in episodes:
cd = base / ep / "CD"
cd.mkdir(parents=True)
for i in (1, 2, 10):
(cd / f"{i:02d} Track.mp3").write_bytes(b"")
cover = base / ep / "Cover"
cover.mkdir()
(cover / "front.jpg").write_bytes(b"")
items = client.get("/api/import/scan").json()["items"]
assert [i["name"] for i in items] == episodes
for item in items:
assert item["is_dir"] and len(item["files"]) == 3
assert [Path(f).name for f in item["files"]] == ["01 Track.mp3", "02 Track.mp3", "10 Track.mp3"]
assert item["rel_dir"] == "Die Drei Fragezeichen/Folgen/3478632869 001-010"
def test_two_discs_next_to_cover(client):
folder = client.downloads / "099 - Die Villa der Toten"
for disc in ("CD 1", "CD 2"):
(folder / disc).mkdir(parents=True)
(folder / disc / "track.mp3").write_bytes(b"")
(folder / "Cover").mkdir()
(folder / "Cover" / "back.png").write_bytes(b"")
items = client.get("/api/import/scan").json()["items"]
assert len(items) == 1
assert items[0]["name"] == "099 - Die Villa der Toten"
assert [Path(f).parent.name for f in items[0]["files"]] == ["CD 1", "CD 2"]
def test_subfolder_with_own_content_is_not_a_disc(client):
"""A real subfolder (an episode) must keep its own identity."""
folder = client.downloads / "Sammlung"
for ep in ("Folge 1", "Folge 2"):
(folder / ep).mkdir(parents=True)
for i in (1, 2):
(folder / ep / f"t{i}.mp3").write_bytes(b"")
items = client.get("/api/import/scan").json()["items"]
assert sorted(i["name"] for i in items) == ["Folge 1", "Folge 2"]
def test_bulk_items_creates_series_and_skips_duplicates(client):
lib_id = client.post("/api/libraries", json={
"name": "DDF", "media_type": "audiobook",
"root_path": str(client.tmp_path / "ddf"),
}).json()["id"]
items = [{"title": f"Die drei ??? Folge {n}", "series": "Die drei ???",
"volume": n, "external_id": f"ASIN{n}"} for n in range(1, 6)]
res = client.post("/api/requests/bulk-items",
json={"library_id": lib_id, "items": items}).json()
assert len(res["created"]) == 5 and res["skipped"] == 0
assert res["created"][0]["media_type"] == "audiobook"
assert res["created"][0]["volume"] == 1
# same call again: known external_ids are skipped, new ones still land
items.append({"title": "Die drei ??? Folge 6", "series": "Die drei ???",
"volume": 6, "external_id": "ASIN6"})
res2 = client.post("/api/requests/bulk-items",
json={"library_id": lib_id, "items": items}).json()
assert res2["skipped"] == 5 and len(res2["created"]) == 1
assert len(client.get("/api/requests", params={"status": "missing"}).json()) == 6
def test_bulk_items_series_then_scan_matches_by_episode_number(client):
"""The whole point: bulk-request a series, then let the scan assign folders."""
lib_id = client.post("/api/libraries", json={
"name": "DDF", "media_type": "audiobook",
"root_path": str(client.tmp_path / "ddf"),
}).json()["id"]
titles = {1: "Die drei ??? und der Super-Papagei",
4: "Die drei ??? und die schwarze Katze",
8: "Die drei ??? und der grüne Geist"}
created = client.post("/api/requests/bulk-items", json={
"library_id": lib_id,
"items": [{"title": t, "series": "Die drei ???", "volume": n,
"external_id": f"ASIN{n}"} for n, t in titles.items()],
}).json()["created"]
by_volume = {r["volume"]: r["id"] for r in created}
base = client.downloads / "Die Drei Fragezeichen" / "Folgen" / "3478632869 001-010"
folders = {1: "001 - Der Super - Papagei", 4: "004 - Die schwarzn Katze",
8: "008 - Der gruene Geist"}
for n, folder in folders.items():
cd = base / folder / "CD"
cd.mkdir(parents=True)
(cd / "01.mp3").write_bytes(b"")
(cd / "02.mp3").write_bytes(b"")
items = client.get("/api/import/scan").json()["items"]
assert len(items) == 3
for item in items:
n = int(item["name"][:3])
assert item["suggested_request_id"] == by_volume[n], item
assert item["score"] >= 90
+26 -1
View File
@@ -1,6 +1,6 @@
from types import SimpleNamespace
from wordarr.importer.matcher import best_matches, normalize
from wordarr.importer.matcher import best_matches, normalize, score
def req(id, title, authors="", media_type="ebook"):
@@ -70,3 +70,28 @@ def test_digit_inside_word_is_not_a_volume():
r3 = req(2, "Die dr3i - Folge 3", media_type="audiobook")
r3.volume = 3
assert score("DiE DR3i - 05 - Der Fall", r) > score("DiE DR3i - 05 - Der Fall", r3)
def test_series_in_folder_path_carries_the_match():
"""Downloads named "001 - Titel" only match via the series in their path."""
r = req(1, "Die drei ??? Folge 001", media_type="audiobook")
r.volume, r.series = 1, "Die drei ???"
item = {"path": "/d/x", "name": "001 - Der Super - Papagei",
"rel_dir": "Die Drei Fragezeichen/Folgen/3478632869 001-010",
"media_type": "audiobook", "is_dir": True, "files": ["/d/x/a.mp3"]}
out = best_matches([item], [r])
assert out[0]["suggested_request_id"] == 1
assert out[0]["score"] >= 90
# without the series context it stays a weak fuzzy guess
assert score("001 - Der Super - Papagei", r) < 90
def test_questionmarks_equal_fragezeichen():
assert normalize("Die drei ???") == normalize("Die Drei Fragezeichen")
def test_disc_count_is_not_an_episode_number():
r = req(1, "Die drei ??? und der Phantomsee", media_type="audiobook")
r.volume = 2
# "(2 CDs)" must not satisfy the volume check of episode 2
assert score("Die drei Fragezeichen 001 - Super-Papagei (2 CDs)", r) == 40.0