fix(ui/ux): let the select buttons include entries without an assignment
This commit is contained in:
@@ -9,6 +9,8 @@ Sonarr/Radarr-Style Request- & Import-Manager für **Ebooks**, **Comics/Mangas**
|
|||||||
2. **Download-Ordner**: wordarr überwacht keinen Downloader aktiv — du legst Dateien selbst in den Download-Ordner (`/mnt/downloads`).
|
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; die Spalte *Library* daneben zeigt, wohin die zugeordnete Anfrage geschrieben wird, und lässt sich dort direkt umstellen. Über 🔍 lässt sich ein Eintrag mit einer bereits offenen Anfrage verbinden — wordarr schlägt passende aus dem Ordnernamen vor, und das Feld *Offene Anfragen* durchsucht auf Wunsch alle (mehrere Begriffe werden mit UND verknüpft, `drei 194` findet Band 194 der Serie). Alternativ lässt sich dort 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**. Die Einträge werden einzeln abgearbeitet: die laufende Zeile ist blau markiert (die Ansicht blättert ihr nach), fertige werden grün, fehlgeschlagene rot mit der Fehlermeldung als Tooltip, und unten läuft ein Balken über alle Einträge. Erfolgreich importierte Zeilen verschwinden nach 15 Sekunden aus der Tabelle (die Liste darunter behält sie); Fehler und Konflikte bleiben stehen, bis du sie erledigt hast. Für den nächsten Durchgang erneut scannen.
|
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; die Spalte *Library* daneben zeigt, wohin die zugeordnete Anfrage geschrieben wird, und lässt sich dort direkt umstellen. Über 🔍 lässt sich ein Eintrag mit einer bereits offenen Anfrage verbinden — wordarr schlägt passende aus dem Ordnernamen vor, und das Feld *Offene Anfragen* durchsucht auf Wunsch alle (mehrere Begriffe werden mit UND verknüpft, `drei 194` findet Band 194 der Serie). Alternativ lässt sich dort 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**. Die Einträge werden einzeln abgearbeitet: die laufende Zeile ist blau markiert (die Ansicht blättert ihr nach), fertige werden grün, fehlgeschlagene rot mit der Fehlermeldung als Tooltip, und unten läuft ein Balken über alle Einträge. Erfolgreich importierte Zeilen verschwinden nach 15 Sekunden aus der Tabelle (die Liste darunter behält sie); Fehler und Konflikte bleiben stehen, bis du sie erledigt hast. Für den nächsten Durchgang erneut scannen.
|
||||||
|
|
||||||
|
Führt Audible eine Reihe nur teilweise (Lady Bedfort etwa mit 34 von 117 Folgen), legt **Anfragen aus Ordnernamen** die fehlenden in einem Zug an: Titel und Folgennummer kommen aus dem Ordnernamen, Serie und Autor gibst du einmal für alle ein, und die neuen Anfragen werden direkt mit ihren Einträgen verbunden. Bereits vorhandene Anfragen werden nicht doppelt angelegt, sondern nur verknüpft.
|
||||||
|
|
||||||
Zum Auswählen gibt es *Alle mit Vorschlag auswählen*, *Diese Seite auswählen* (nur die gerade sichtbaren 25, alles andere wird abgewählt — praktisch, um in Etappen zu importieren) und *Alle abwählen*. Ordner mit mehreren Audio-Dateien werden als ein Audiobook behandelt (`Titel - Part 01.mp3`, …) — der Download-Ordner selbst aber nie: lose Dateien direkt darin erscheinen einzeln, damit sie die Unterordner nicht verdecken. Enthält ein Ordner dagegen **je Datei ein eigenes Buch** (eine Reihe als Einzeltitel gekauft), teilt ✂️ ihn in einzelne Einträge auf — der Button erscheint nur bei solchen Ordnern und bei Zusammenfassungen; wordarr markiert sie 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: *zusammenfassen* (bei einem Mehrteiler) oder *überspringen* — dann werden nur die betroffenen Einträge zurückgestellt, rot markiert und über den Filter *Nur Konflikte* wiedergefunden, während alles andere importiert wird. Ohne diese Abfrage würde nur der erste Eintrag importiert und der Rest scheitern. 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.
|
Zum Auswählen gibt es *Alle mit Vorschlag auswählen*, *Diese Seite auswählen* (nur die gerade sichtbaren 25, alles andere wird abgewählt — praktisch, um in Etappen zu importieren) und *Alle abwählen*. Ordner mit mehreren Audio-Dateien werden als ein Audiobook behandelt (`Titel - Part 01.mp3`, …) — der Download-Ordner selbst aber nie: lose Dateien direkt darin erscheinen einzeln, damit sie die Unterordner nicht verdecken. Enthält ein Ordner dagegen **je Datei ein eigenes Buch** (eine Reihe als Einzeltitel gekauft), teilt ✂️ ihn in einzelne Einträge auf — der Button erscheint nur bei solchen Ordnern und bei Zusammenfassungen; wordarr markiert sie 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: *zusammenfassen* (bei einem Mehrteiler) oder *überspringen* — dann werden nur die betroffenen Einträge zurückgestellt, rot markiert und über den Filter *Nur Konflikte* wiedergefunden, während alles andere importiert wird. Ohne diese Abfrage würde nur der erste Eintrag importiert und der Rest scheitern. 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)
|
## Setup (Docker)
|
||||||
@@ -36,6 +38,14 @@ Audible führt Reihen manchmal anders, als man sie ablegen möchte (*Harry Potte
|
|||||||
|
|
||||||
Linux erlaubt in Dateinamen beliebige Bytes, ältere Rips tragen ihre Umlaute deshalb manchmal als Latin-1 (`Die Fu\xdfball-Falle` statt UTF-8). Python liest solche Namen mit Ersatzzeichen ein, die sich nicht als JSON ausliefern lassen — früher scheiterte daran der **gesamte** Scan mit einem 500er. wordarr zeigt betroffene Einträge jetzt mit `�` an und überträgt den Pfad verlustfrei im Hintergrund, sodass Scan und Import normal funktionieren. Beim Import bekommt die Datei ohnehin den Namen aus dem Schema — danach ist der Name sauber.
|
Linux erlaubt in Dateinamen beliebige Bytes, ältere Rips tragen ihre Umlaute deshalb manchmal als Latin-1 (`Die Fu\xdfball-Falle` statt UTF-8). Python liest solche Namen mit Ersatzzeichen ein, die sich nicht als JSON ausliefern lassen — früher scheiterte daran der **gesamte** Scan mit einem 500er. wordarr zeigt betroffene Einträge jetzt mit `�` an und überträgt den Pfad verlustfrei im Hintergrund, sodass Scan und Import normal funktionieren. Beim Import bekommt die Datei ohnehin den Namen aus dem Schema — danach ist der Name sauber.
|
||||||
|
|
||||||
|
## Metadaten-Quellen
|
||||||
|
|
||||||
|
Audiobooks werden bei **Audible** gesucht und parallel bei **MusicBrainz**, dessen Treffer hinter den Audible-Ergebnissen stehen und mit der Quelle gekennzeichnet sind. MusicBrainz erfasst deutsche Hörspielserien gut und liefert oft die **Erstveröffentlichung** statt einer späteren Audible-Neuauflage (`Die drei ??? 100: Toteninsel` mit 2001 statt 2022) sowie Folgennummern, die dort im Titel stehen. Gesucht wird nur in Audio-Dramen und Hörbüchern, sonst kämen zu jedem Serientitel tausende Musikalben zurück.
|
||||||
|
|
||||||
|
Der Dienst begrenzt auf eine Anfrage pro Sekunde; wordarr hält das ein und fragt ihn deshalb nur bei einer ausdrücklichen Suche, nicht bei der automatischen Suche beim Öffnen des Import-Dialogs. Fällt MusicBrainz aus, bleiben die Audible-Treffer davon unberührt. Der Serien-Crawl („Ganze Serie…") nutzt weiterhin nur Audible, da nur dort Serien über eine ASIN zusammenhängen. Über `WORDARR_MUSICBRAINZ_UA` lässt sich der User-Agent setzen, den MusicBrainz verlangt.
|
||||||
|
|
||||||
|
Was beide nicht kennen (Lady Bedfort etwa führt Audible nur mit 34 von 117 Folgen, MusicBrainz gar nicht), legst du am schnellsten über *Anfragen aus Ordnernamen* an.
|
||||||
|
|
||||||
## Sprachen
|
## Sprachen
|
||||||
|
|
||||||
Jede Library kann eine **Sprache** tragen (`Deutsch`/`Englisch`, Default: egal). Audible liefert zu jedem Titel die Sprache mit, deshalb zeigt die Suche für eine englische Library nur englische Ausgaben und für eine deutsche nur deutsche — praktisch, wenn dieselbe Reihe in beiden Sprachen in getrennten Libraries liegt (*A Song of Ice and Fire* vs. *Das Lied von Eis und Feuer*). Gesucht wird dann auch auf dem passenden Marktplatz (`audible.com` bzw. `audible.de`), was die Trefferqualität deutlich hebt. Im Tab *Suche* lässt sich die Sprache zusätzlich frei filtern; im Import-Dialog kommt sie automatisch aus der gewählten Ziel-Library.
|
Jede Library kann eine **Sprache** tragen (`Deutsch`/`Englisch`, Default: egal). Audible liefert zu jedem Titel die Sprache mit, deshalb zeigt die Suche für eine englische Library nur englische Ausgaben und für eine deutsche nur deutsche — praktisch, wenn dieselbe Reihe in beiden Sprachen in getrennten Libraries liegt (*A Song of Ice and Fire* vs. *Das Lied von Eis und Feuer*). Gesucht wird dann auch auf dem passenden Marktplatz (`audible.com` bzw. `audible.de`), was die Trefferqualität deutlich hebt. Im Tab *Suche* lässt sich die Sprache zusätzlich frei filtern; im Import-Dialog kommt sie automatisch aus der gewählten Ziel-Library.
|
||||||
|
|||||||
+117
-11
@@ -3,6 +3,7 @@ const $$ = (sel) => document.querySelectorAll(sel);
|
|||||||
|
|
||||||
let libraries = [];
|
let libraries = [];
|
||||||
const LANGUAGE_NAMES = { german: "Deutsch", english: "Englisch" };
|
const LANGUAGE_NAMES = { german: "Deutsch", english: "Englisch" };
|
||||||
|
const SOURCE_NAMES = { musicbrainz: "MusicBrainz" };
|
||||||
|
|
||||||
async function api(path, opts = {}) {
|
async function api(path, opts = {}) {
|
||||||
const resp = await fetch(path, {
|
const resp = await fetch(path, {
|
||||||
@@ -220,7 +221,7 @@ $("#search-form").addEventListener("submit", async (e) => {
|
|||||||
<span>${esc(r.authors)}</span>
|
<span>${esc(r.authors)}</span>
|
||||||
${r.narrator ? `<span class="muted">🎙 ${esc(r.narrator)}</span>` : ""}
|
${r.narrator ? `<span class="muted">🎙 ${esc(r.narrator)}</span>` : ""}
|
||||||
${r.series && r.media_type ? `<span class="muted">📚 ${esc(r.series)}${r.volume != null ? " #" + r.volume : ""}</span>` : ""}
|
${r.series && r.media_type ? `<span class="muted">📚 ${esc(r.series)}${r.volume != null ? " #" + r.volume : ""}</span>` : ""}
|
||||||
<span class="muted">${r.year ?? ""} ${r.language ? "· " + esc(LANGUAGE_NAMES[r.language] || r.language) : ""} ${r.external_id ? "· " + esc(r.external_id) : ""}</span>
|
<span class="muted">${r.year ?? ""} ${r.language ? "· " + esc(LANGUAGE_NAMES[r.language] || r.language) : ""} ${r.source && r.source !== "audible" ? "· " + esc(SOURCE_NAMES[r.source] || r.source) : ""} ${r.external_id ? "· " + esc(r.external_id) : ""}</span>
|
||||||
${type === "comic" ? `<input type="number" min="0" placeholder="Band" class="volume" data-vol="${i}">` : ""}
|
${type === "comic" ? `<input type="number" min="0" placeholder="Band" class="volume" data-vol="${i}">` : ""}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<select data-lib="${i}">${opts || "<option value=''>— keine Library —</option>"}</select>
|
<select data-lib="${i}">${opts || "<option value=''>— keine Library —</option>"}</select>
|
||||||
@@ -890,20 +891,31 @@ $("#import-select-page").addEventListener("click", () => {
|
|||||||
applyImportView();
|
applyImportView();
|
||||||
const start = importPage * IMPORT_PAGE_SIZE;
|
const start = importPage * IMPORT_PAGE_SIZE;
|
||||||
const pageIdx = viewIdx.slice(start, start + IMPORT_PAGE_SIZE);
|
const pageIdx = viewIdx.slice(start, start + IMPORT_PAGE_SIZE);
|
||||||
// exclusive on purpose: "import what I see", not "add to what was selected"
|
selectIndices(pageIdx, "dieser Seite");
|
||||||
scanItems.forEach((it) => (it.checked = false));
|
});
|
||||||
let n = 0;
|
|
||||||
pageIdx.forEach((i) => {
|
$("#import-select-all").addEventListener("click", () => {
|
||||||
|
applyImportView();
|
||||||
|
selectIndices(viewIdx, "insgesamt");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Entries without a suggestion are selected too - they are exactly the ones
|
||||||
|
// "Anfragen aus Ordnernamen" works on.
|
||||||
|
function selectIndices(indices, what) {
|
||||||
|
scanItems.forEach((it) => (it.checked = false)); // "take what I see", not "add"
|
||||||
|
indices.forEach((i) => {
|
||||||
const item = scanItems[i];
|
const item = scanItems[i];
|
||||||
const req = item.request_id || item.suggested_request_id;
|
const req = item.request_id || item.suggested_request_id;
|
||||||
if (!req) return;
|
if (req) item.request_id = req;
|
||||||
item.request_id = req;
|
|
||||||
item.checked = true;
|
item.checked = true;
|
||||||
n += 1;
|
|
||||||
});
|
});
|
||||||
renderImportTable();
|
renderImportTable();
|
||||||
toast(n ? `${n} Einträge dieser Seite ausgewählt` : "Auf dieser Seite gibt es keine Zuordnung", !n);
|
const withReq = indices.filter((i) => scanItems[i].request_id).length;
|
||||||
});
|
toast(
|
||||||
|
`${indices.length} Einträge ${what} ausgewählt` +
|
||||||
|
(withReq < indices.length ? `, davon ${indices.length - withReq} ohne Zuordnung` : "")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
$("#import-deselect-all").addEventListener("click", () => {
|
$("#import-deselect-all").addEventListener("click", () => {
|
||||||
applyImportView();
|
applyImportView();
|
||||||
@@ -911,6 +923,96 @@ $("#import-deselect-all").addEventListener("click", () => {
|
|||||||
renderImportTable();
|
renderImportTable();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- create requests straight from folder names ----
|
||||||
|
// For series that Audible only knows in part (Lady Bedfort: 34 of 117) the
|
||||||
|
// folder names are the better source - they carry number and title already.
|
||||||
|
let namesTargets = [];
|
||||||
|
|
||||||
|
function nameToRequest(item) {
|
||||||
|
const { number, rest } = splitEpisodeNumber(item.name);
|
||||||
|
return { title: cleanFileName(rest) || item.name, volume: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
$("#import-from-names").addEventListener("click", () => {
|
||||||
|
namesTargets = scanItems.filter((it) => it.checked && !it.request_id);
|
||||||
|
if (!namesTargets.length) {
|
||||||
|
toast("Keine ausgewählten Einträge ohne Zuordnung", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const type = namesTargets[0].media_type;
|
||||||
|
namesTargets = namesTargets.filter((it) => it.media_type === type);
|
||||||
|
$("#names-intro").textContent =
|
||||||
|
`${namesTargets.length} Einträge ohne Zuordnung (${type}). Titel und Folgennummer ` +
|
||||||
|
"kommen aus dem Ordnernamen; Serie und Autor gelten für alle.";
|
||||||
|
$("#names-series").value = lastSeries;
|
||||||
|
$("#names-authors").value = "";
|
||||||
|
$("#names-library").innerHTML = libOptions(type) || "<option value=''>— keine Library —</option>";
|
||||||
|
renderNamesPreview();
|
||||||
|
$("#names-dialog").showModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderNamesPreview() {
|
||||||
|
const shown = namesTargets.slice(0, 12);
|
||||||
|
$("#names-preview").innerHTML =
|
||||||
|
shown
|
||||||
|
.map((it) => {
|
||||||
|
const { title, volume } = nameToRequest(it);
|
||||||
|
return `<label><span class="num">${volume ?? "—"}</span><span>${esc(title)}</span></label>`;
|
||||||
|
})
|
||||||
|
.join("") +
|
||||||
|
(namesTargets.length > shown.length
|
||||||
|
? `<div class="gap">… und ${namesTargets.length - shown.length} weitere</div>`
|
||||||
|
: "");
|
||||||
|
}
|
||||||
|
|
||||||
|
$("#names-series").addEventListener("input", renderNamesPreview);
|
||||||
|
$("#names-cancel").addEventListener("click", () => $("#names-dialog").close());
|
||||||
|
|
||||||
|
$("#names-submit").addEventListener("click", async () => {
|
||||||
|
const libId = $("#names-library").value;
|
||||||
|
if (!libId) { toast("Erst eine Library für diesen Typ anlegen", true); return; }
|
||||||
|
const series = (lastSeries = $("#names-series").value.trim());
|
||||||
|
const authors = $("#names-authors").value.trim();
|
||||||
|
const btn = $("#names-submit");
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<span class="spinner"></span> Lege an…';
|
||||||
|
try {
|
||||||
|
const items = namesTargets.map((it) => ({ ...nameToRequest(it), series, authors }));
|
||||||
|
const res = await api("/api/requests/bulk-items", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ library_id: parseInt(libId), items }),
|
||||||
|
});
|
||||||
|
// reload so that entries skipped as duplicates can be linked to the request
|
||||||
|
// that already existed
|
||||||
|
missingReqs = await api("/api/requests?status=missing");
|
||||||
|
const key = (t, v) => `${(t || "").trim().toLowerCase()}|${v ?? ""}`;
|
||||||
|
const byKey = new Map(missingReqs.map((r) => [key(r.title, r.volume), r]));
|
||||||
|
let linked = 0;
|
||||||
|
namesTargets.forEach((it) => {
|
||||||
|
const { title, volume } = nameToRequest(it);
|
||||||
|
const req = byKey.get(key(title, volume));
|
||||||
|
if (!req) return;
|
||||||
|
it.request_id = req.id;
|
||||||
|
it.checked = true;
|
||||||
|
linked += 1;
|
||||||
|
});
|
||||||
|
setLastLib(namesTargets[0].media_type, libId);
|
||||||
|
$("#names-dialog").close();
|
||||||
|
renderImportTable();
|
||||||
|
refreshMissingBadge();
|
||||||
|
toast(
|
||||||
|
`${res.created.length} Anfragen angelegt` +
|
||||||
|
(res.skipped ? `, ${res.skipped} gab es schon` : "") +
|
||||||
|
`, ${linked} Einträge verbunden`
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
toast(err.message, true);
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = "Anlegen & verbinden";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ---- multi-part episodes: import several entries as one audiobook ----
|
// ---- multi-part episodes: import several entries as one audiobook ----
|
||||||
// e.g. "100 - Toteninsel Teil 1/2/3", which Audible lists as a single title
|
// e.g. "100 - Toteninsel Teil 1/2/3", which Audible lists as a single title
|
||||||
function commonPrefix(names) {
|
function commonPrefix(names) {
|
||||||
@@ -995,6 +1097,7 @@ function splitItem(i) {
|
|||||||
let quickItemIndex = null;
|
let quickItemIndex = null;
|
||||||
let quickEpisode = null;
|
let quickEpisode = null;
|
||||||
let quickPicked = null; // metadata hit the manual fields were filled from
|
let quickPicked = null; // metadata hit the manual fields were filled from
|
||||||
|
let quickAutoSearch = false; // the search fired on open, not by the user
|
||||||
let lastSeries = "";
|
let lastSeries = "";
|
||||||
|
|
||||||
// "017 - Titel", "Folge 17: Titel", "[003] Titel" -> episode number + rest.
|
// "017 - Titel", "Folge 17: Titel", "[003] Titel" -> episode number + rest.
|
||||||
@@ -1050,6 +1153,7 @@ function openQuickDialog(i) {
|
|||||||
$("#quick-results").innerHTML = "";
|
$("#quick-results").innerHTML = "";
|
||||||
renderQuickOpenRequests(item);
|
renderQuickOpenRequests(item);
|
||||||
$("#quick-dialog").showModal();
|
$("#quick-dialog").showModal();
|
||||||
|
quickAutoSearch = true; // opening the dialog searches Audible only
|
||||||
runQuickSearch();
|
runQuickSearch();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1117,8 +1221,9 @@ async function runQuickSearch() {
|
|||||||
const lib = libraries.find((l) => String(l.id) === $("#quick-library").value);
|
const lib = libraries.find((l) => String(l.id) === $("#quick-library").value);
|
||||||
const results = await api(
|
const results = await api(
|
||||||
`/api/search?media_type=${item.media_type}&q=${encodeURIComponent($("#quick-q").value)}` +
|
`/api/search?media_type=${item.media_type}&q=${encodeURIComponent($("#quick-q").value)}` +
|
||||||
`&language=${lib?.language || ""}`
|
`&language=${lib?.language || ""}&fallback=${quickAutoSearch ? "false" : "true"}`
|
||||||
);
|
);
|
||||||
|
quickAutoSearch = false;
|
||||||
if (!results.length) {
|
if (!results.length) {
|
||||||
box.innerHTML = `<p class='muted'>${EMPTY_HINTS[item.media_type]}</p>`;
|
box.innerHTML = `<p class='muted'>${EMPTY_HINTS[item.media_type]}</p>`;
|
||||||
return;
|
return;
|
||||||
@@ -1132,6 +1237,7 @@ async function runQuickSearch() {
|
|||||||
r.series ? `📚 ${r.series}${r.volume != null ? " #" + r.volume : ""}` : "",
|
r.series ? `📚 ${r.series}${r.volume != null ? " #" + r.volume : ""}` : "",
|
||||||
r.year ?? "",
|
r.year ?? "",
|
||||||
LANGUAGE_NAMES[r.language] || r.language,
|
LANGUAGE_NAMES[r.language] || r.language,
|
||||||
|
r.source && r.source !== "audible" ? SOURCE_NAMES[r.source] || r.source : "",
|
||||||
].filter(Boolean).map(esc).join(" · ");
|
].filter(Boolean).map(esc).join(" · ");
|
||||||
return `<div class="card">
|
return `<div class="card">
|
||||||
${r.cover_url ? `<img src="${esc(r.cover_url)}" alt="" loading="lazy">` : '<div class="nocover">?</div>'}
|
${r.cover_url ? `<img src="${esc(r.cover_url)}" alt="" loading="lazy">` : '<div class="nocover">?</div>'}
|
||||||
|
|||||||
@@ -165,6 +165,10 @@
|
|||||||
<button id="import-select-suggested" class="secondary">
|
<button id="import-select-suggested" class="secondary">
|
||||||
Alle mit Vorschlag auswählen
|
Alle mit Vorschlag auswählen
|
||||||
</button>
|
</button>
|
||||||
|
<button id="import-select-all" class="secondary"
|
||||||
|
title="Alle Einträge der aktuellen Ansicht auswählen, auch ohne Zuordnung">
|
||||||
|
Alle auswählen
|
||||||
|
</button>
|
||||||
<button id="import-select-page" class="secondary"
|
<button id="import-select-page" class="secondary"
|
||||||
title="Nur die Einträge dieser Seite auswählen, alle anderen abwählen">
|
title="Nur die Einträge dieser Seite auswählen, alle anderen abwählen">
|
||||||
Diese Seite auswählen
|
Diese Seite auswählen
|
||||||
@@ -175,6 +179,10 @@
|
|||||||
<button id="import-merge" class="secondary" title="Mehrteiler: ausgewählte Einträge als ein Hörbuch importieren">
|
<button id="import-merge" class="secondary" title="Mehrteiler: ausgewählte Einträge als ein Hörbuch importieren">
|
||||||
Ausgewählte zusammenfassen
|
Ausgewählte zusammenfassen
|
||||||
</button>
|
</button>
|
||||||
|
<button id="import-from-names" class="secondary"
|
||||||
|
title="Für ausgewählte Einträge ohne Zuordnung Anfragen aus den Ordnernamen anlegen">
|
||||||
|
Anfragen aus Ordnernamen
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-scroll">
|
<div class="table-scroll">
|
||||||
<table id="import-table" hidden>
|
<table id="import-table" hidden>
|
||||||
@@ -206,6 +214,21 @@
|
|||||||
<div id="import-summary"></div>
|
<div id="import-summary"></div>
|
||||||
<div id="import-results"></div>
|
<div id="import-results"></div>
|
||||||
|
|
||||||
|
<dialog id="names-dialog">
|
||||||
|
<h3>Anfragen aus Ordnernamen</h3>
|
||||||
|
<p class="muted" id="names-intro"></p>
|
||||||
|
<div class="bar wrap">
|
||||||
|
<label>Serie <input id="names-series" placeholder="optional, für alle" /></label>
|
||||||
|
<label>Autor <input id="names-authors" placeholder="optional, für alle" /></label>
|
||||||
|
<label>Library <select id="names-library"></select></label>
|
||||||
|
</div>
|
||||||
|
<div id="names-preview" class="series-list"></div>
|
||||||
|
<div class="row">
|
||||||
|
<button type="button" id="names-cancel" class="secondary">Abbrechen</button>
|
||||||
|
<button type="button" id="names-submit">Anlegen & verbinden</button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
<dialog id="conflict-dialog">
|
<dialog id="conflict-dialog">
|
||||||
<h3>Mehrfach zugeordnete Anfragen</h3>
|
<h3>Mehrfach zugeordnete Anfragen</h3>
|
||||||
<p class="muted" id="conflict-intro"></p>
|
<p class="muted" id="conflict-intro"></p>
|
||||||
|
|||||||
@@ -677,7 +677,7 @@ def test_search_passes_language_to_the_provider(client, monkeypatch):
|
|||||||
from wordarr.metadata.base import MetadataResult
|
from wordarr.metadata.base import MetadataResult
|
||||||
seen = {}
|
seen = {}
|
||||||
|
|
||||||
async def fake(query, language=""):
|
async def fake(query, language="", fallback=True):
|
||||||
seen["query"], seen["language"] = query, language
|
seen["query"], seen["language"] = query, language
|
||||||
return [MetadataResult(media_type="audiobook", title="A Game of Thrones",
|
return [MetadataResult(media_type="audiobook", title="A Game of Thrones",
|
||||||
language="english")]
|
language="english")]
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""The MusicBrainz fallback for audio dramas Audible does not carry."""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from wordarr import metadata
|
||||||
|
from wordarr.metadata import musicbrainz
|
||||||
|
|
||||||
|
RELEASE = {
|
||||||
|
"id": "23d80453-0d56-4064-85ec-cf3a14823897",
|
||||||
|
"title": "Die drei ??? 100: Toteninsel",
|
||||||
|
"date": "2001-10-15",
|
||||||
|
"text-representation": {"language": "deu"},
|
||||||
|
"artist-credit": [{"name": "Die drei ???"}],
|
||||||
|
"release-group": {"secondary-types": ["Audio drama"]},
|
||||||
|
}
|
||||||
|
REISSUE = {**RELEASE, "id": "other", "date": "2011-08-13"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mb_calls(monkeypatch):
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
seen.append(dict(request.url.params))
|
||||||
|
return httpx.Response(200, json={"releases": [REISSUE, RELEASE]})
|
||||||
|
|
||||||
|
real = httpx.AsyncClient
|
||||||
|
monkeypatch.setattr(musicbrainz.httpx, "AsyncClient",
|
||||||
|
lambda **kw: real(transport=httpx.MockTransport(handler), **kw))
|
||||||
|
monkeypatch.setattr(musicbrainz, "MIN_INTERVAL", 0)
|
||||||
|
return seen
|
||||||
|
|
||||||
|
|
||||||
|
def test_parses_series_number_and_original_date(mb_calls):
|
||||||
|
results = asyncio.run(musicbrainz.search("Die drei ??? Toteninsel", "german"))
|
||||||
|
assert len(results) == 1 # both pressings collapse into one
|
||||||
|
hit = results[0]
|
||||||
|
assert (hit.series, hit.volume, hit.title) == ("Die drei ???", 100, "Toteninsel")
|
||||||
|
assert hit.year == 2001 # the original, not the 2011 reissue
|
||||||
|
assert hit.language == "german"
|
||||||
|
assert hit.source == "musicbrainz"
|
||||||
|
assert hit.external_id.startswith("mb:")
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_drops_lucene_specials_and_filters_to_audio_drama(mb_calls):
|
||||||
|
asyncio.run(musicbrainz.search("Die drei ??? Toteninsel", "german"))
|
||||||
|
query = mb_calls[0]["query"]
|
||||||
|
assert "???" not in query # would act as wildcards
|
||||||
|
assert "release:Toteninsel" in query and " AND " in query
|
||||||
|
assert 'secondarytype:"audio drama"' in query
|
||||||
|
assert "lang:deu" in query
|
||||||
|
|
||||||
|
|
||||||
|
def test_audiobook_search_appends_musicbrainz_hits(mb_calls, monkeypatch):
|
||||||
|
from wordarr.metadata.base import MetadataResult
|
||||||
|
|
||||||
|
async def fake_audible(query, language=""):
|
||||||
|
return [MetadataResult(media_type="audiobook", title="Etwas anderes")]
|
||||||
|
|
||||||
|
monkeypatch.setattr(metadata.audible, "search", fake_audible)
|
||||||
|
results = asyncio.run(metadata.audiobook_search("Toteninsel", "german"))
|
||||||
|
assert [r.source for r in results] == ["audible", "musicbrainz"]
|
||||||
|
|
||||||
|
# the automatic search when the dialog opens must not hit MusicBrainz
|
||||||
|
mb_calls.clear()
|
||||||
|
results = asyncio.run(metadata.audiobook_search("Toteninsel", "german", fallback=False))
|
||||||
|
assert [r.source for r in results] == ["audible"] and not mb_calls
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_broken_fallback_never_breaks_the_search(monkeypatch):
|
||||||
|
from wordarr.metadata.base import MetadataResult
|
||||||
|
|
||||||
|
async def fake_audible(query, language=""):
|
||||||
|
return [MetadataResult(media_type="audiobook", title="Da")]
|
||||||
|
|
||||||
|
async def boom(query, language=""):
|
||||||
|
raise RuntimeError("musicbrainz down")
|
||||||
|
|
||||||
|
monkeypatch.setattr(metadata.audible, "search", fake_audible)
|
||||||
|
monkeypatch.setattr(metadata.musicbrainz, "search", boom)
|
||||||
|
results = asyncio.run(metadata.audiobook_search("Toteninsel"))
|
||||||
|
assert [r.title for r in results] == ["Da"]
|
||||||
@@ -7,12 +7,12 @@ router = APIRouter(prefix="/api/search", tags=["search"])
|
|||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[MetadataResult])
|
@router.get("", response_model=list[MetadataResult])
|
||||||
async def search(media_type: str, q: str, language: str = ""):
|
async def search(media_type: str, q: str, language: str = "", fallback: bool = True):
|
||||||
provider = PROVIDERS.get(media_type)
|
provider = PROVIDERS.get(media_type)
|
||||||
if not provider:
|
if not provider:
|
||||||
raise HTTPException(400, f"invalid media_type: {media_type}")
|
raise HTTPException(400, f"invalid media_type: {media_type}")
|
||||||
try:
|
try:
|
||||||
return await provider(q, language)
|
return await provider(q, language, fallback)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise HTTPException(502, f"metadata provider error: {exc}")
|
raise HTTPException(502, f"metadata provider error: {exc}")
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,41 @@
|
|||||||
from . import anilist, audible, openlibrary
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from . import anilist, audible, musicbrainz, openlibrary
|
||||||
|
from .base import MetadataResult
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def audiobook_search(query: str, language: str = "",
|
||||||
|
fallback: bool = True) -> list[MetadataResult]:
|
||||||
|
"""Audible first, MusicBrainz alongside it.
|
||||||
|
|
||||||
|
Audible answers almost any query with *something*, so "no good hit" cannot be
|
||||||
|
detected reliably - measuring it showed the fallback would never fire. Both
|
||||||
|
are therefore asked in parallel and the MusicBrainz hits are appended, marked
|
||||||
|
with their source. Callers that search automatically (the import dialog when
|
||||||
|
it opens) pass fallback=False to spare the rate limited service.
|
||||||
|
"""
|
||||||
|
if not fallback:
|
||||||
|
return await audible.search(query, language)
|
||||||
|
|
||||||
|
hits, extra = await asyncio.gather(
|
||||||
|
audible.search(query, language),
|
||||||
|
musicbrainz.search(query, language),
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
if isinstance(hits, BaseException):
|
||||||
|
raise hits
|
||||||
|
if isinstance(extra, BaseException): # a slow fallback must not break search
|
||||||
|
log.warning("musicbrainz lookup failed for %r: %s", query, extra)
|
||||||
|
return hits
|
||||||
|
seen = {(r.title.strip().lower(), r.volume) for r in hits}
|
||||||
|
return hits + [r for r in extra if (r.title.strip().lower(), r.volume) not in seen]
|
||||||
|
|
||||||
|
|
||||||
PROVIDERS = {
|
PROVIDERS = {
|
||||||
"ebook": openlibrary.search,
|
"ebook": openlibrary.search,
|
||||||
"audiobook": audible.search,
|
"audiobook": audiobook_search,
|
||||||
"comic": anilist.search,
|
"comic": anilist.search,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ query ($search: String) {
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
async def search(query: str, language: str = "") -> list[MetadataResult]:
|
async def search(query: str, language: str = "",
|
||||||
|
fallback: bool = True) -> list[MetadataResult]:
|
||||||
# language is an Audible concept; AniList results carry no usable language tag
|
# language is an Audible concept; AniList results carry no usable language tag
|
||||||
async with httpx.AsyncClient(timeout=15) as client:
|
async with httpx.AsyncClient(timeout=15) as client:
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ class MetadataResult(BaseModel):
|
|||||||
external_id: str = ""
|
external_id: str = ""
|
||||||
year: int | None = None
|
year: int | None = None
|
||||||
language: str = "" # audible: "english", "german", …
|
language: str = "" # audible: "english", "german", …
|
||||||
|
source: str = "audible" # which provider produced this hit
|
||||||
series: str = ""
|
series: str = ""
|
||||||
series_id: str = ""
|
series_id: str = ""
|
||||||
volume: int | None = None
|
volume: int | None = None
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"""MusicBrainz as a fallback for audio dramas Audible does not carry.
|
||||||
|
|
||||||
|
German Hörspiel series are well covered there, often with the episode number in
|
||||||
|
the title and the *original* release date instead of a later Audible reissue.
|
||||||
|
The service asks for one request per second and a descriptive user agent; both
|
||||||
|
are honoured here, which makes it deliberately slower than Audible.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from .base import MetadataResult
|
||||||
|
|
||||||
|
BASE = "https://musicbrainz.org/ws/2/release"
|
||||||
|
USER_AGENT = os.environ.get(
|
||||||
|
"WORDARR_MUSICBRAINZ_UA", "wordarr/0.1 ( https://github.com/wordarr )"
|
||||||
|
)
|
||||||
|
# audio drama / audiobook only, otherwise "Lady Bedfort" returns 8000 pop songs
|
||||||
|
MEDIA_FILTER = '(secondarytype:"audio drama" OR secondarytype:audiobook)'
|
||||||
|
LANGUAGES = {"german": "deu", "english": "eng"}
|
||||||
|
MIN_INTERVAL = 1.1 # seconds between requests, per their rate limit
|
||||||
|
RESULT_LIMIT = 15
|
||||||
|
|
||||||
|
# "Die drei ??? 100: Toteninsel" -> series, number, title
|
||||||
|
TITLE_RE = re.compile(r"^(?P<series>.+?)\s+(?P<num>\d{1,4})\s*[:.\-–]\s*(?P<title>.+)$")
|
||||||
|
|
||||||
|
_lock = asyncio.Lock()
|
||||||
|
_last_call = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
async def _get(client: httpx.AsyncClient, query: str) -> dict:
|
||||||
|
"""One rate limited request; a 503 means we were still too fast."""
|
||||||
|
global _last_call
|
||||||
|
async with _lock:
|
||||||
|
wait = MIN_INTERVAL - (time.monotonic() - _last_call)
|
||||||
|
if wait > 0:
|
||||||
|
await asyncio.sleep(wait)
|
||||||
|
for attempt in (1, 2):
|
||||||
|
resp = await client.get(
|
||||||
|
BASE,
|
||||||
|
params={"query": query, "fmt": "json", "limit": RESULT_LIMIT},
|
||||||
|
headers={"User-Agent": USER_AGENT},
|
||||||
|
)
|
||||||
|
_last_call = time.monotonic()
|
||||||
|
if resp.status_code == 200:
|
||||||
|
return resp.json()
|
||||||
|
if resp.status_code != 503 or attempt == 2:
|
||||||
|
return {}
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _to_result(release: dict) -> MetadataResult:
|
||||||
|
artists = [a.get("name", "") for a in release.get("artist-credit") or []
|
||||||
|
if isinstance(a, dict) and a.get("name")]
|
||||||
|
artist = artists[0] if artists else ""
|
||||||
|
title = release.get("title", "")
|
||||||
|
series, volume = artist, None
|
||||||
|
match = TITLE_RE.match(title)
|
||||||
|
if match:
|
||||||
|
series = match.group("series").strip()
|
||||||
|
volume = int(match.group("num"))
|
||||||
|
title = match.group("title").strip()
|
||||||
|
language = (release.get("text-representation") or {}).get("language", "")
|
||||||
|
date = release.get("date") or ""
|
||||||
|
return MetadataResult(
|
||||||
|
media_type="audiobook",
|
||||||
|
title=title,
|
||||||
|
# for audio dramas the credited "artist" is the series, not an author
|
||||||
|
authors="",
|
||||||
|
series=series,
|
||||||
|
volume=volume,
|
||||||
|
language={v: k for k, v in LANGUAGES.items()}.get(language, ""),
|
||||||
|
external_id=f"mb:{release.get('id', '')}",
|
||||||
|
year=int(date[:4]) if date[:4].isdigit() else None,
|
||||||
|
source="musicbrainz",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Lucene metacharacters, "???" in a series name would act as wildcards
|
||||||
|
LUCENE_SPECIAL = re.compile(r'[+\-&|!(){}\[\]^"~*?:\\/]+')
|
||||||
|
|
||||||
|
|
||||||
|
def _terms(query: str) -> str:
|
||||||
|
"""Words joined with AND - a quoted phrase would miss "Die drei ??? 100:
|
||||||
|
Toteninsel" when searching for "Die drei ??? Toteninsel"."""
|
||||||
|
words = [w for w in LUCENE_SPECIAL.sub(" ", query).split() if len(w) > 1][:8]
|
||||||
|
return " AND ".join(f"release:{w}" for w in words)
|
||||||
|
|
||||||
|
|
||||||
|
async def search(query: str, language: str = "") -> list[MetadataResult]:
|
||||||
|
words = _terms(query)
|
||||||
|
if not words:
|
||||||
|
return []
|
||||||
|
terms = [f"({words})", MEDIA_FILTER]
|
||||||
|
if language in LANGUAGES:
|
||||||
|
terms.append(f"lang:{LANGUAGES[language]}")
|
||||||
|
async with httpx.AsyncClient(timeout=25, follow_redirects=True) as client:
|
||||||
|
data = await _get(client, " AND ".join(terms))
|
||||||
|
|
||||||
|
# the same audio drama exists as many pressings; keep the oldest of each,
|
||||||
|
# that is the original release rather than a reissue
|
||||||
|
best: dict[tuple, MetadataResult] = {}
|
||||||
|
for release in data.get("releases", []):
|
||||||
|
result = _to_result(release)
|
||||||
|
if not result.title:
|
||||||
|
continue
|
||||||
|
key = (result.title.lower(), result.series.lower(), result.volume)
|
||||||
|
current = best.get(key)
|
||||||
|
if not current or (result.year and (not current.year or result.year < current.year)):
|
||||||
|
best[key] = result
|
||||||
|
return sorted(best.values(), key=lambda r: (r.volume is None, r.volume or 0, r.title))
|
||||||
@@ -7,7 +7,8 @@ from .base import MetadataResult
|
|||||||
_ISBN = re.compile(r"^(97[89])?\d{9}[\dXx]$")
|
_ISBN = re.compile(r"^(97[89])?\d{9}[\dXx]$")
|
||||||
|
|
||||||
|
|
||||||
async def search(query: str, language: str = "") -> list[MetadataResult]:
|
async def search(query: str, language: str = "",
|
||||||
|
fallback: bool = True) -> list[MetadataResult]:
|
||||||
# language is an Audible concept; Open Library results carry no usable language tag
|
# language is an Audible concept; Open Library results carry no usable language tag
|
||||||
query = query.strip()
|
query = query.strip()
|
||||||
isbn = query.replace("-", "").replace(" ", "")
|
isbn = query.replace("-", "").replace(" ", "")
|
||||||
|
|||||||
Reference in New Issue
Block a user