fix(matcher): use folder path as series context
This commit is contained in:
@@ -5,8 +5,9 @@ Sonarr/Radarr-Style Request- & Import-Manager für **Ebooks**, **Comics/Mangas**
|
||||
## Funktionsweise
|
||||
|
||||
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, einzelne können fehlen und werden im Dialog als Lücke angezeigt.
|
||||
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; du bestätigst oder korrigierst. 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`, …). Auch Ordner, die in Disc-Unterordner (`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.
|
||||
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. 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`, …). 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.
|
||||
|
||||
## Setup (Docker)
|
||||
|
||||
|
||||
+117
@@ -219,11 +219,15 @@ $("#search-form").addEventListener("submit", async (e) => {
|
||||
<div class="row">
|
||||
<select data-lib="${i}">${opts || "<option value=''>— keine Library —</option>"}</select>
|
||||
<button data-req="${i}">Anfragen</button>
|
||||
${r.series_id ? `<button class="secondary" data-series="${i}">Ganze Serie…</button>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>`
|
||||
)
|
||||
.join("");
|
||||
box.querySelectorAll("[data-series]").forEach((btn) =>
|
||||
btn.addEventListener("click", () => openSeriesDialog(results[btn.dataset.series]))
|
||||
);
|
||||
box.querySelectorAll("[data-req]").forEach((btn) =>
|
||||
btn.addEventListener("click", async () => {
|
||||
const i = btn.dataset.req;
|
||||
@@ -255,6 +259,119 @@ $("#search-form").addEventListener("submit", async (e) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ---- request a whole series ----
|
||||
let seriesEpisodes = [];
|
||||
|
||||
function renderSeriesList() {
|
||||
const from = parseInt($("#series-from").value);
|
||||
const to = parseInt($("#series-to").value);
|
||||
const inRange = (e) =>
|
||||
(isNaN(from) || (e.volume != null && e.volume >= from)) &&
|
||||
(isNaN(to) || (e.volume != null && e.volume <= to));
|
||||
let previous = null;
|
||||
$("#series-list").innerHTML = seriesEpisodes
|
||||
.map((e, i) => {
|
||||
// make holes in the numbering visible - the crawl cannot reach every episode
|
||||
let gap = "";
|
||||
if (e.volume != null && previous != null && e.volume > previous + 1) {
|
||||
const missing = e.volume - previous - 1;
|
||||
gap = `<div class="gap">… ${missing} Folge${missing > 1 ? "n" : ""} nicht bei Audible gefunden (${previous + 1}–${e.volume - 1})</div>`;
|
||||
}
|
||||
if (e.volume != null) previous = e.volume;
|
||||
return `${gap}<label>
|
||||
<input type="checkbox" data-ep="${i}" ${inRange(e) ? "checked" : ""}>
|
||||
<span class="num">${e.volume ?? "—"}</span>
|
||||
<span>${esc(e.title)}</span>
|
||||
</label>`;
|
||||
})
|
||||
.join("");
|
||||
updateSeriesCount();
|
||||
}
|
||||
|
||||
function seriesChecked() {
|
||||
return [...$("#series-list").querySelectorAll("input:checked")].map(
|
||||
(c) => seriesEpisodes[c.dataset.ep]
|
||||
);
|
||||
}
|
||||
|
||||
function updateSeriesCount() {
|
||||
const n = seriesChecked().length;
|
||||
const btn = $("#series-submit");
|
||||
btn.textContent = n ? `${n} anfragen` : "Anfragen";
|
||||
btn.disabled = n === 0;
|
||||
}
|
||||
|
||||
async function openSeriesDialog(result) {
|
||||
const dlg = $("#series-dialog");
|
||||
$("#series-title").textContent = result.series || "Serie";
|
||||
$("#series-info").innerHTML = '<span class="spinner"></span> Folgen werden gesucht…';
|
||||
$("#series-list").innerHTML = "";
|
||||
$("#series-from").value = "";
|
||||
$("#series-to").value = "";
|
||||
$("#series-library").innerHTML = libOptions("audiobook") || "<option value=''>— keine Library —</option>";
|
||||
$("#series-submit").disabled = true;
|
||||
dlg.showModal();
|
||||
try {
|
||||
seriesEpisodes = await api(
|
||||
`/api/search/series?series_id=${encodeURIComponent(result.series_id)}` +
|
||||
`&title=${encodeURIComponent(result.series || result.title)}`
|
||||
);
|
||||
} catch (err) {
|
||||
$("#series-info").textContent = err.message;
|
||||
return;
|
||||
}
|
||||
const numbered = seriesEpisodes.filter((e) => e.volume != null);
|
||||
$("#series-info").textContent =
|
||||
`${seriesEpisodes.length} Folgen gefunden` +
|
||||
(numbered.length ? ` (Nr. ${numbered[0].volume}–${numbered[numbered.length - 1].volume})` : "") +
|
||||
". Audible hat keine Serien-Abfrage — einzelne Folgen können fehlen.";
|
||||
renderSeriesList();
|
||||
}
|
||||
|
||||
$("#series-from").addEventListener("input", renderSeriesList);
|
||||
$("#series-to").addEventListener("input", renderSeriesList);
|
||||
$("#series-list").addEventListener("change", updateSeriesCount);
|
||||
$("#series-all").addEventListener("click", () => {
|
||||
$("#series-list").querySelectorAll("input").forEach((c) => (c.checked = true));
|
||||
updateSeriesCount();
|
||||
});
|
||||
$("#series-none").addEventListener("click", () => {
|
||||
$("#series-list").querySelectorAll("input").forEach((c) => (c.checked = false));
|
||||
updateSeriesCount();
|
||||
});
|
||||
$("#series-cancel").addEventListener("click", () => $("#series-dialog").close());
|
||||
|
||||
$("#series-submit").addEventListener("click", async () => {
|
||||
const libId = $("#series-library").value;
|
||||
if (!libId) { toast("Erst eine Audiobook-Library anlegen (Tab Libraries)", true); return; }
|
||||
const items = seriesChecked().map((e) => ({
|
||||
title: e.title, authors: e.authors, narrator: e.narrator || "",
|
||||
external_id: e.external_id, year: e.year, series: e.series,
|
||||
volume: e.volume, cover_url: e.cover_url,
|
||||
}));
|
||||
const btn = $("#series-submit");
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner"></span> Lege an…';
|
||||
try {
|
||||
const res = await api("/api/requests/bulk-items", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ library_id: parseInt(libId), items }),
|
||||
});
|
||||
setLastLib("audiobook", libId);
|
||||
toast(
|
||||
`${res.created.length} Anfragen angelegt` +
|
||||
(res.skipped ? `, ${res.skipped} bereits vorhanden` : "")
|
||||
);
|
||||
$("#series-dialog").close();
|
||||
refreshMissingBadge();
|
||||
} catch (err) {
|
||||
toast(err.message, true);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
updateSeriesCount();
|
||||
}
|
||||
});
|
||||
|
||||
// ---- manual request ----
|
||||
$("#manual-btn").addEventListener("click", () => {
|
||||
const dlg = $("#manual-dialog");
|
||||
|
||||
@@ -73,6 +73,25 @@
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="series-dialog">
|
||||
<h3 id="series-title">Serie anfragen</h3>
|
||||
<p class="muted" id="series-info"></p>
|
||||
<div class="bar wrap">
|
||||
<label>von Folge <input id="series-from" type="number" min="0" /></label>
|
||||
<label>bis Folge <input id="series-to" type="number" min="0" /></label>
|
||||
<button type="button" id="series-all" class="secondary">Alle</button>
|
||||
<button type="button" id="series-none" class="secondary">Keine</button>
|
||||
</div>
|
||||
<div id="series-list" class="series-list"></div>
|
||||
<div class="row">
|
||||
<label>Library <select id="series-library"></select></label>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button type="button" id="series-cancel" class="secondary">Abbrechen</button>
|
||||
<button type="button" id="series-submit">Anfragen</button>
|
||||
</div>
|
||||
</dialog>
|
||||
</section>
|
||||
|
||||
<section id="view-missing" hidden>
|
||||
|
||||
@@ -97,6 +97,13 @@ dialog label { display: flex; flex-direction: column; gap: 0.2rem; margin: 0.5re
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
#quick-dialog { min-width: 480px; }
|
||||
#series-dialog { min-width: 520px; }
|
||||
.series-list { max-height: 45vh; overflow-y: auto; margin: 0.6rem 0; border: 1px solid var(--border); border-radius: 6px; }
|
||||
.series-list label { flex-direction: row; align-items: center; gap: 0.5rem; margin: 0; padding: 0.35rem 0.6rem; color: var(--text); font-size: 0.9rem; }
|
||||
.series-list label:nth-child(even) { background: rgba(255,255,255,0.03); }
|
||||
.series-list input[type=checkbox] { flex: none; }
|
||||
.series-list .num { flex: none; min-width: 3.2em; color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||
.series-list .gap { padding: 0.35rem 0.6rem; color: var(--muted); font-size: 0.85rem; }
|
||||
#quick-results { display: flex; flex-direction: column; max-height: 50vh; overflow-y: auto; margin-top: 0.6rem; }
|
||||
.ok { color: var(--ok); }
|
||||
.error { color: #e08585; }
|
||||
@@ -110,5 +117,6 @@ h3, h4 { margin: 0.8rem 0 0.6rem; }
|
||||
main { padding: 0.8rem; }
|
||||
.cards { grid-template-columns: 1fr; }
|
||||
#quick-dialog { min-width: 0; width: 92vw; }
|
||||
#series-dialog { min-width: 0; width: 92vw; }
|
||||
td select { max-width: 200px; }
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
+52
-2
@@ -12,8 +12,7 @@ from ..importer import tagger
|
||||
router = APIRouter(prefix="/api/requests", tags=["requests"])
|
||||
|
||||
|
||||
class RequestIn(BaseModel):
|
||||
library_id: int
|
||||
class BulkItem(BaseModel):
|
||||
title: str
|
||||
authors: str = ""
|
||||
narrator: str = ""
|
||||
@@ -24,6 +23,10 @@ class RequestIn(BaseModel):
|
||||
cover_url: str = ""
|
||||
|
||||
|
||||
class RequestIn(BulkItem):
|
||||
library_id: int
|
||||
|
||||
|
||||
class RequestOut(RequestIn):
|
||||
id: int
|
||||
media_type: str
|
||||
@@ -127,6 +130,53 @@ def create_bulk(data: BulkRequestIn, session: Session = Depends(get_session)):
|
||||
return [_out(r) for r in reqs]
|
||||
|
||||
|
||||
class BulkItemsIn(BaseModel):
|
||||
library_id: int
|
||||
items: list[BulkItem]
|
||||
|
||||
|
||||
@router.post("/bulk-items")
|
||||
def create_bulk_items(data: BulkItemsIn, session: Session = Depends(get_session)):
|
||||
"""Create many requests at once from ready-made metadata (e.g. a whole
|
||||
Audible series). Entries that already exist as missing are skipped."""
|
||||
lib = session.get(Library, data.library_id)
|
||||
if not lib:
|
||||
raise HTTPException(404, "library not found")
|
||||
if len(data.items) > 1000:
|
||||
raise HTTPException(400, "at most 1000 requests per bulk call")
|
||||
|
||||
open_reqs = session.scalars(
|
||||
select(BookRequest).where(
|
||||
BookRequest.status == "missing",
|
||||
BookRequest.media_type == lib.media_type,
|
||||
)
|
||||
).all()
|
||||
known_ids = {r.external_id for r in open_reqs if r.external_id}
|
||||
known_titles = {
|
||||
(r.title.strip().lower(), r.narrator or "", r.volume) for r in open_reqs
|
||||
}
|
||||
|
||||
created, skipped = [], 0
|
||||
for item in data.items:
|
||||
key = (item.title.strip().lower(), item.narrator or "", item.volume)
|
||||
if (item.external_id and item.external_id in known_ids) or key in known_titles:
|
||||
skipped += 1
|
||||
continue
|
||||
known_ids.add(item.external_id)
|
||||
known_titles.add(key)
|
||||
req = BookRequest(
|
||||
media_type=lib.media_type,
|
||||
library_id=lib.id,
|
||||
**item.model_dump(),
|
||||
)
|
||||
session.add(req)
|
||||
created.append(req)
|
||||
session.commit()
|
||||
for r in created:
|
||||
session.refresh(r)
|
||||
return {"created": [_out(r) for r in created], "skipped": skipped}
|
||||
|
||||
|
||||
@router.put("/{request_id}", response_model=RequestOut)
|
||||
def update_request(request_id: int, data: RequestIn, session: Session = Depends(get_session)):
|
||||
req = session.get(BookRequest, request_id)
|
||||
|
||||
+14
-1
@@ -1,6 +1,6 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from ..metadata import PROVIDERS
|
||||
from ..metadata import PROVIDERS, audible
|
||||
from ..metadata.base import MetadataResult
|
||||
|
||||
router = APIRouter(prefix="/api/search", tags=["search"])
|
||||
@@ -15,3 +15,16 @@ async def search(media_type: str, q: str):
|
||||
return await provider(q)
|
||||
except Exception as exc:
|
||||
raise HTTPException(502, f"metadata provider error: {exc}")
|
||||
|
||||
|
||||
@router.get("/series", response_model=list[MetadataResult])
|
||||
async def series(series_id: str, title: str):
|
||||
"""All episodes of an Audible series, sorted by episode number. Audible has
|
||||
no series endpoint, so this is a paged keyword crawl and may miss a few
|
||||
episodes - those can still be added by hand."""
|
||||
if not series_id:
|
||||
raise HTTPException(400, "series_id required")
|
||||
try:
|
||||
return await audible.series_episodes(series_id, title)
|
||||
except Exception as exc:
|
||||
raise HTTPException(502, f"metadata provider error: {exc}")
|
||||
|
||||
@@ -16,8 +16,12 @@ def _deleet(s: str) -> str:
|
||||
|
||||
def normalize(s: str) -> str:
|
||||
s = s.lower()
|
||||
s = re.sub(r"[._\-\[\]()]+", " ", s)
|
||||
s = s.replace("???", " fragezeichen ") # "Die drei ???" == "Die drei Fragezeichen"
|
||||
s = re.sub(r"[._\-\[\]()?]+", " ", s)
|
||||
s = re.sub(r"\b(epub|pdf|mobi|azw3|m4b|mp3|flac|cbz|cbr|retail|unabridged|audiobook|ebook|v\d+)\b", " ", s)
|
||||
# disc noise: "(2 CDs)", "CD1", "Disc 3" - the count is not an episode number
|
||||
s = re.sub(r"\b\d+\s*(?:cds?|discs?|disks?)\b", " ", s)
|
||||
s = re.sub(r"\b(?:cd|disc|disk|dvd)[\s._-]*\d*\b", " ", s)
|
||||
s = _deleet(s)
|
||||
s = re.sub(r"\s+", " ", s)
|
||||
return s.strip()
|
||||
@@ -28,7 +32,7 @@ def _numbers(s: str) -> set[int]:
|
||||
return {int(n) for n in re.findall(r"\b\d+\b", normalize(s))}
|
||||
|
||||
|
||||
def score(item_name: str, request) -> float:
|
||||
def score(item_name: str, request, rel_dir: str = "") -> float:
|
||||
target = normalize(f"{request.authors} {request.title}")
|
||||
name = normalize(item_name)
|
||||
s = fuzz.token_set_ratio(name, target)
|
||||
@@ -47,6 +51,13 @@ def score(item_name: str, request) -> float:
|
||||
result = min(100.0, result + 5)
|
||||
elif nums:
|
||||
result = min(result, 40.0)
|
||||
# series in the folder path + matching episode number is a strong signal
|
||||
# even when the file name carries only the episode title ("001 - Titel")
|
||||
series = getattr(request, "series", "") or ""
|
||||
if series and request.volume in nums:
|
||||
context = normalize(f"{rel_dir} {item_name}")
|
||||
if fuzz.partial_ratio(normalize(series), context) > 85:
|
||||
result = max(result, 90.0)
|
||||
return result
|
||||
|
||||
|
||||
@@ -57,7 +68,7 @@ def best_matches(items: list[dict], requests) -> list[dict]:
|
||||
candidates = [r for r in requests if r.media_type == item["media_type"]]
|
||||
best, best_score = None, 0.0
|
||||
for r in candidates:
|
||||
sc = score(item["name"], r)
|
||||
sc = score(item["name"], r, item.get("rel_dir", ""))
|
||||
if sc > best_score:
|
||||
best, best_score = r, sc
|
||||
out.append({
|
||||
|
||||
+23
-12
@@ -3,10 +3,11 @@ from pathlib import Path
|
||||
|
||||
from .. import config
|
||||
|
||||
# "CD1", "CD 2", "Disc_03", "Teil 1", "Part 2" as folder name. A bare number is
|
||||
# deliberately not a disc: those folders are usually episodes of a series.
|
||||
# "CD", "CD1", "CD 2", "Disc_03", "Teil 1", "Part 2" as folder name. The number is
|
||||
# optional (single-disc rips just use "CD"). A bare number is deliberately not a
|
||||
# disc: those folders are usually episodes of a series.
|
||||
DISC_DIR_RE = re.compile(
|
||||
r"^(?:cd|disc|disk|dvd|teil|part|vol|volume)[\s._-]*(\d{1,3})$",
|
||||
r"^(?:cd|disc|disk|dvd|teil|part|vol|volume)[\s._-]*(\d{1,3})?$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
@@ -28,8 +29,12 @@ def natural_key(name: str) -> list:
|
||||
|
||||
|
||||
def disc_number(name: str) -> int | None:
|
||||
"""Disc index of a disc folder name, 0 for an unnumbered "CD". None if the
|
||||
name is not a disc folder at all."""
|
||||
m = DISC_DIR_RE.match(name.strip())
|
||||
return int(m.group(1)) if m else None
|
||||
if not m:
|
||||
return None
|
||||
return int(m.group(1)) if m.group(1) else 0
|
||||
|
||||
|
||||
def scan(root: Path, split_dirs: bool = False) -> list[dict]:
|
||||
@@ -52,17 +57,23 @@ def scan(root: Path, split_dirs: bool = False) -> list[dict]:
|
||||
def subdirs(d: Path) -> list[Path]:
|
||||
return [s for s in d.iterdir() if s.is_dir() and not s.name.startswith(".")]
|
||||
|
||||
def has_audio(d: Path) -> bool:
|
||||
return any(
|
||||
f.suffix.lower() in config.AUDIOBOOK_EXTENSIONS
|
||||
for f in d.rglob("*") if f.is_file()
|
||||
)
|
||||
|
||||
def disc_audio(d: Path) -> list[Path] | None:
|
||||
"""Audio files of d's disc subfolders (CD1, CD2, …), in disc order.
|
||||
None if d is not a multi-disc folder."""
|
||||
subs = subdirs(d)
|
||||
if not subs:
|
||||
return None
|
||||
"""Audio files of d's disc subfolders ("CD", "CD1", "CD2", …), in disc
|
||||
order. Subfolders without audio (Cover, Scans, …) are ignored. None if d
|
||||
is not a disc-split folder."""
|
||||
discs = []
|
||||
for s in subs:
|
||||
for s in subdirs(d):
|
||||
if not has_audio(s):
|
||||
continue # artwork/booklet folder, not part of the audiobook
|
||||
num = disc_number(s.name)
|
||||
if num is None or not audio_files(s):
|
||||
return None # not purely a disc split -> walk normally
|
||||
if num is None:
|
||||
return None # a real subfolder -> not a disc split, walk normally
|
||||
discs.append((num, s))
|
||||
files = []
|
||||
for _, s in sorted(discs, key=lambda t: (t[0], natural_key(t[1].name))):
|
||||
|
||||
+96
-27
@@ -8,14 +8,30 @@ from .base import MetadataResult
|
||||
# Comma-separated marketplace TLDs, first entries rank first in the results.
|
||||
REGIONS = [r.strip() for r in os.environ.get("WORDARR_AUDIBLE_REGIONS", "de,com").split(",")]
|
||||
|
||||
RESPONSE_GROUPS = "media,contributors,product_desc,product_attrs,series"
|
||||
PAGE_SIZE = 50
|
||||
# Audible has no "list a series" endpoint (series_asin is rejected), so a series
|
||||
# is collected by paging through keyword searches and keeping the products that
|
||||
# carry its asin. One query never returns the whole catalog, hence the variants.
|
||||
SERIES_QUERY_SUFFIXES = ["", " und der", " und die", " und das", " Folge"]
|
||||
SERIES_MAX_PAGES = 12
|
||||
SERIES_MAX_CALLS = 90
|
||||
# Audible drops requests when hit with a dozen at once, so pages go out in chunks
|
||||
SERIES_CHUNK = 4
|
||||
|
||||
async def _search_region(client: httpx.AsyncClient, tld: str, query: str) -> list[dict]:
|
||||
|
||||
async def _query(client: httpx.AsyncClient, tld: str, query: str, num_results: int = 10,
|
||||
page: int = 1, sort: str | None = "Relevance") -> list[dict]:
|
||||
params = {
|
||||
"keywords": query,
|
||||
"num_results": 10,
|
||||
"response_groups": "media,contributors,product_desc,product_attrs,series",
|
||||
"products_sort_by": "Relevance",
|
||||
"num_results": num_results,
|
||||
"page": page,
|
||||
"response_groups": RESPONSE_GROUPS,
|
||||
}
|
||||
# sorting by relevance helps the top-10 search but measurably shrinks what a
|
||||
# deep paged sweep returns, so the series crawl leaves it off
|
||||
if sort:
|
||||
params["products_sort_by"] = sort
|
||||
try:
|
||||
resp = await client.get(f"https://api.audible.{tld}/1.0/catalog/products", params=params)
|
||||
resp.raise_for_status()
|
||||
@@ -24,10 +40,33 @@ async def _search_region(client: httpx.AsyncClient, tld: str, query: str) -> lis
|
||||
return []
|
||||
|
||||
|
||||
def _to_result(p: dict) -> MetadataResult:
|
||||
images = p.get("product_images") or {}
|
||||
release = p.get("release_date") or ""
|
||||
series_list = p.get("series") or []
|
||||
first = series_list[0] if series_list else {}
|
||||
try:
|
||||
volume = int(float(first.get("sequence", "")))
|
||||
except (TypeError, ValueError):
|
||||
volume = None
|
||||
return MetadataResult(
|
||||
media_type="audiobook",
|
||||
title=p.get("title", ""),
|
||||
authors=", ".join(a.get("name", "") for a in p.get("authors") or []),
|
||||
narrator=", ".join(n.get("name", "") for n in p.get("narrators") or []),
|
||||
series=first.get("title", ""),
|
||||
series_id=first.get("asin", ""),
|
||||
volume=volume,
|
||||
external_id=p.get("asin", ""),
|
||||
year=int(release[:4]) if release[:4].isdigit() else None,
|
||||
cover_url=next(iter(images.values()), ""),
|
||||
)
|
||||
|
||||
|
||||
async def search(query: str) -> list[MetadataResult]:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
region_results = await asyncio.gather(
|
||||
*(_search_region(client, tld, query) for tld in REGIONS)
|
||||
*(_query(client, tld, query) for tld in REGIONS)
|
||||
)
|
||||
|
||||
results = []
|
||||
@@ -38,26 +77,56 @@ async def search(query: str) -> list[MetadataResult]:
|
||||
if asin in seen_asins:
|
||||
continue
|
||||
seen_asins.add(asin)
|
||||
images = p.get("product_images") or {}
|
||||
release = p.get("release_date") or ""
|
||||
series_list = p.get("series") or []
|
||||
series = series_list[0].get("title", "") if series_list else ""
|
||||
seq = series_list[0].get("sequence", "") if series_list else ""
|
||||
try:
|
||||
volume = int(float(seq))
|
||||
except (TypeError, ValueError):
|
||||
volume = None
|
||||
results.append(
|
||||
MetadataResult(
|
||||
media_type="audiobook",
|
||||
title=p.get("title", ""),
|
||||
authors=", ".join(a.get("name", "") for a in p.get("authors") or []),
|
||||
narrator=", ".join(n.get("name", "") for n in p.get("narrators") or []),
|
||||
series=series,
|
||||
volume=volume,
|
||||
external_id=asin,
|
||||
year=int(release[:4]) if release[:4].isdigit() else None,
|
||||
cover_url=next(iter(images.values()), ""),
|
||||
)
|
||||
)
|
||||
results.append(_to_result(p))
|
||||
return results
|
||||
|
||||
|
||||
def _series_asins(p: dict, series_id: str) -> dict | None:
|
||||
for s in p.get("series") or []:
|
||||
if s.get("asin") == series_id:
|
||||
return s
|
||||
return None
|
||||
|
||||
|
||||
async def series_episodes(series_id: str, series_title: str) -> list[MetadataResult]:
|
||||
"""Every episode of the given series that the keyword search can reach,
|
||||
sorted by episode number. Episodes without a number are appended at the end."""
|
||||
queries = [f"{series_title}{suffix}" for suffix in SERIES_QUERY_SUFFIXES]
|
||||
if "???" in series_title:
|
||||
queries.insert(1, series_title.replace("???", "Fragezeichen"))
|
||||
|
||||
by_seq: dict[int, MetadataResult] = {}
|
||||
extra: list[MetadataResult] = []
|
||||
seen_asins: set[str] = set()
|
||||
calls = 0
|
||||
|
||||
async with httpx.AsyncClient(timeout=25) as client:
|
||||
for tld in REGIONS:
|
||||
for query in queries:
|
||||
if calls >= SERIES_MAX_CALLS:
|
||||
break
|
||||
pages = min(SERIES_MAX_PAGES, SERIES_MAX_CALLS - calls)
|
||||
for start in range(1, pages + 1, SERIES_CHUNK):
|
||||
chunk = range(start, min(start + SERIES_CHUNK, pages + 1))
|
||||
calls += len(chunk)
|
||||
results = await asyncio.gather(*(
|
||||
_query(client, tld, query, num_results=PAGE_SIZE, page=page, sort=None)
|
||||
for page in chunk
|
||||
))
|
||||
if not any(results):
|
||||
break # past the last page of this query
|
||||
for products in results:
|
||||
for p in products:
|
||||
asin = p.get("asin", "")
|
||||
if asin in seen_asins or not _series_asins(p, series_id):
|
||||
continue
|
||||
seen_asins.add(asin)
|
||||
res = _to_result(p)
|
||||
if res.volume is None:
|
||||
extra.append(res)
|
||||
else:
|
||||
by_seq.setdefault(res.volume, res)
|
||||
if by_seq or extra:
|
||||
break # the asin is marketplace specific, one region owns it
|
||||
|
||||
return [by_seq[k] for k in sorted(by_seq)] + extra
|
||||
|
||||
@@ -9,6 +9,7 @@ class MetadataResult(BaseModel):
|
||||
external_id: str = ""
|
||||
year: int | None = None
|
||||
series: str = ""
|
||||
series_id: str = ""
|
||||
volume: int | None = None
|
||||
cover_url: str = ""
|
||||
description: str = ""
|
||||
|
||||
Reference in New Issue
Block a user