From 0844d303db884f1217bfc3e2cc0dfa0022388aa2 Mon Sep 17 00:00:00 2001 From: Steppenstreuner Date: Fri, 28 Aug 2026 20:01:15 +0200 Subject: [PATCH] fix(matcher): use folder path as series context --- README.md | 3 +- static/app.js | 117 ++++++++++++++++++++++++++++++++++ static/index.html | 19 ++++++ static/style.css | 8 +++ tests/test_import_flow.py | 103 ++++++++++++++++++++++++++++++ tests/test_matcher.py | 27 +++++++- wordarr/api/requests.py | 54 +++++++++++++++- wordarr/api/search.py | 15 ++++- wordarr/importer/matcher.py | 17 ++++- wordarr/importer/scanner.py | 35 ++++++---- wordarr/metadata/audible.py | 123 ++++++++++++++++++++++++++++-------- wordarr/metadata/base.py | 1 + 12 files changed, 475 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 0060687..70cc936 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/static/app.js b/static/app.js index ca917da..b23a004 100644 --- a/static/app.js +++ b/static/app.js @@ -219,11 +219,15 @@ $("#search-form").addEventListener("submit", async (e) => {
+ ${r.series_id ? `` : ""}
` ) .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 = `
… ${missing} Folge${missing > 1 ? "n" : ""} nicht bei Audible gefunden (${previous + 1}–${e.volume - 1})
`; + } + if (e.volume != null) previous = e.volume; + return `${gap}`; + }) + .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 = ' Folgen werden gesucht…'; + $("#series-list").innerHTML = ""; + $("#series-from").value = ""; + $("#series-to").value = ""; + $("#series-library").innerHTML = libOptions("audiobook") || ""; + $("#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 = ' 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"); diff --git a/static/index.html b/static/index.html index 59f16c5..04a6cd0 100644 --- a/static/index.html +++ b/static/index.html @@ -73,6 +73,25 @@ + + +

Serie anfragen

+

+
+ + + + +
+
+
+ +
+
+ + +
+