add(ui/ux): skip conflicting entries instead of blocking the whole import
This commit is contained in:
@@ -7,7 +7,7 @@ Sonarr/Radarr-Style Request- & Import-Manager für **Ebooks**, **Comics/Mangas**
|
|||||||
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**.
|
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; sollte eine fehlen, zeigt der Dialog die Lücke an.
|
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; sollte eine fehlen, zeigt der Dialog die Lücke an.
|
||||||
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. Über 🔍 lässt sich zu einem Ordner 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. Die Liste bleibt danach stehen, damit das Ergebnis sichtbar bleibt — für den nächsten Durchgang erneut scannen. Ordner mit mehreren Audio-Dateien werden als ein Audiobook behandelt (`Titel - Part 01.mp3`, …). Enthält ein Ordner dagegen **je Datei ein eigenes Buch** (eine Reihe als Einzeltitel gekauft), teilt ✂️ ihn in einzelne Einträge auf; wordarr markiert solche Ordner 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 und fasst sie zusammen — sonst importiert nur der erste und der Rest scheitert. 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.
|
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. Über 🔍 lässt sich zu einem Ordner 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. Die Liste bleibt danach stehen, damit das Ergebnis sichtbar bleibt — für den nächsten Durchgang erneut scannen. Ordner mit mehreren Audio-Dateien werden als ein Audiobook behandelt (`Titel - Part 01.mp3`, …). Enthält ein Ordner dagegen **je Datei ein eigenes Buch** (eine Reihe als Einzeltitel gekauft), teilt ✂️ ihn in einzelne Einträge auf; wordarr markiert solche Ordner 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)
|
||||||
|
|
||||||
|
|||||||
+73
-19
@@ -649,6 +649,7 @@ $("#detail-form").addEventListener("submit", async (e) => {
|
|||||||
let scanItems = [];
|
let scanItems = [];
|
||||||
let missingReqs = [];
|
let missingReqs = [];
|
||||||
let importedReqs = [];
|
let importedReqs = [];
|
||||||
|
let skippedConflicts = 0;
|
||||||
let orphanedIds = new Set(); // imported, but their files are gone from the library
|
let orphanedIds = new Set(); // imported, but their files are gone from the library
|
||||||
let viewIdx = []; // indices into scanItems after filter/sort
|
let viewIdx = []; // indices into scanItems after filter/sort
|
||||||
let importPage = 0;
|
let importPage = 0;
|
||||||
@@ -698,6 +699,7 @@ function applyImportView() {
|
|||||||
if (text && !(`${item.name} ${item.rel_dir || ""}`.toLowerCase().includes(text))) return false;
|
if (text && !(`${item.name} ${item.rel_dir || ""}`.toLowerCase().includes(text))) return false;
|
||||||
if (mode === "suggested" && !item.suggested_request_id) return false;
|
if (mode === "suggested" && !item.suggested_request_id) return false;
|
||||||
if (mode === "unassigned" && item.request_id) return false;
|
if (mode === "unassigned" && item.request_id) return false;
|
||||||
|
if (mode === "conflict" && !item.conflict) return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
if (sort === "score") viewIdx.sort((a, b) => scanItems[b].score - scanItems[a].score);
|
if (sort === "score") viewIdx.sort((a, b) => scanItems[b].score - scanItems[a].score);
|
||||||
@@ -745,7 +747,7 @@ function renderImportTable() {
|
|||||||
.filter((r) => r.media_type === item.media_type && r.imported_path)
|
.filter((r) => r.media_type === item.media_type && r.imported_path)
|
||||||
.map((r) => option(r, " ↩︎"))
|
.map((r) => option(r, " ↩︎"))
|
||||||
.join("");
|
.join("");
|
||||||
return `<tr class="${item.importState ? "row-" + item.importState : ""}">
|
return `<tr class="${item.importState ? "row-" + item.importState : item.conflict ? "row-conflict" : ""}">
|
||||||
<td><input type="checkbox" data-check="${i}" ${item.checked ? "checked" : ""}></td>
|
<td><input type="checkbox" data-check="${i}" ${item.checked ? "checked" : ""}></td>
|
||||||
<td class="mono">
|
<td class="mono">
|
||||||
${esc(item.name)}${item.is_dir ? " 📁" : ""}
|
${esc(item.name)}${item.is_dir ? " 📁" : ""}
|
||||||
@@ -765,7 +767,9 @@ function renderImportTable() {
|
|||||||
? `<button class="secondary" data-split="${i}" title="${item.parts ? "Zusammenfassung wieder auflösen" : "Ordner in einzelne Titel aufteilen"}">✂️</button>`
|
? `<button class="secondary" data-split="${i}" title="${item.parts ? "Zusammenfassung wieder auflösen" : "Ordner in einzelne Titel aufteilen"}">✂️</button>`
|
||||||
: ""}
|
: ""}
|
||||||
</td>
|
</td>
|
||||||
${item.importState ? importStateCell(item) : scoreCell(item)}
|
${item.importState ? importStateCell(item)
|
||||||
|
: item.conflict ? '<td class="state conflict" title="Mehrere Einträge zeigen auf dieselbe Anfrage">⚠ Konflikt</td>'
|
||||||
|
: scoreCell(item)}
|
||||||
</tr>`;
|
</tr>`;
|
||||||
})
|
})
|
||||||
.join("");
|
.join("");
|
||||||
@@ -1140,26 +1144,18 @@ const IMPORT_BATCH_SIZE = 20;
|
|||||||
|
|
||||||
// several entries on one request means one audiobook split across folders:
|
// several entries on one request means one audiobook split across folders:
|
||||||
// importing them one by one only imports the first and fails the rest
|
// importing them one by one only imports the first and fails the rest
|
||||||
function collapseSharedRequests(chosen, opts = {}) {
|
function groupByRequest(chosen) {
|
||||||
const byRequest = new Map();
|
const byRequest = new Map();
|
||||||
for (const item of chosen) {
|
for (const item of chosen) {
|
||||||
const group = byRequest.get(item.request_id);
|
const group = byRequest.get(item.request_id);
|
||||||
if (group) group.push(item);
|
if (group) group.push(item);
|
||||||
else byRequest.set(item.request_id, [item]);
|
else byRequest.set(item.request_id, [item]);
|
||||||
}
|
}
|
||||||
const shared = [...byRequest.values()].filter((g) => g.length > 1);
|
return [...byRequest.values()];
|
||||||
if (shared.length) {
|
}
|
||||||
const names = shared
|
|
||||||
.map((g) => `• ${g.map((it) => it.name).join(" + ")}`)
|
function buildImportGroups(groups) {
|
||||||
.join("\n");
|
return groups.map((group) => ({
|
||||||
const ok = confirm(
|
|
||||||
`${shared.length} Anfrage(n) sind mehreren Einträgen zugeordnet:\n\n${names}\n\n` +
|
|
||||||
"Als je ein Hörbuch mit durchlaufenden Parts importieren?\n" +
|
|
||||||
"(Abbrechen: nichts wird importiert)"
|
|
||||||
);
|
|
||||||
if (!ok) return null;
|
|
||||||
}
|
|
||||||
const built = [...byRequest.values()].map((group) => ({
|
|
||||||
parts: group,
|
parts: group,
|
||||||
item: {
|
item: {
|
||||||
path: group.length > 1 ? sharedParent(group) : group[0].path,
|
path: group.length > 1 ? sharedParent(group) : group[0].path,
|
||||||
@@ -1169,7 +1165,54 @@ function collapseSharedRequests(chosen, opts = {}) {
|
|||||||
append: importedReqs.some((r) => r.id === group[0].request_id), // orphans re-import
|
append: importedReqs.some((r) => r.id === group[0].request_id), // orphans re-import
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
return opts.keepGroups ? built : built.map((b) => b.item);
|
}
|
||||||
|
|
||||||
|
// Several entries on one request is either a multi-part title (merge them) or a
|
||||||
|
// mismatch (skip them). The user decides once, then keeps the rest importable.
|
||||||
|
function askAboutSharedRequests(shared) {
|
||||||
|
const dlg = $("#conflict-dialog");
|
||||||
|
$("#conflict-intro").textContent =
|
||||||
|
`${shared.length} Anfrage(n) sind mehreren Einträgen zugeordnet. Gehören die Einträge ` +
|
||||||
|
"zusammen (Mehrteiler), lassen sie sich als ein Hörbuch importieren — sonst " +
|
||||||
|
"überspringst du sie, korrigierst die Zuordnung und importierst sie später.";
|
||||||
|
$("#conflict-list").innerHTML = shared
|
||||||
|
.map((group) => {
|
||||||
|
const req = missingReqs.find((r) => r.id === group[0].request_id);
|
||||||
|
return `<label><span>${esc(req ? req.title : "#" + group[0].request_id)}</span>
|
||||||
|
<span class="muted">← ${group.map((it) => esc(it.name)).join(" + ")}</span></label>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
dlg.showModal();
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const done = (answer) => {
|
||||||
|
dlg.close();
|
||||||
|
resolve(answer);
|
||||||
|
};
|
||||||
|
$("#conflict-merge").onclick = () => done("merge");
|
||||||
|
$("#conflict-skip").onclick = () => done("skip");
|
||||||
|
$("#conflict-cancel").onclick = () => done("cancel");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collapseSharedRequests(chosen) {
|
||||||
|
const groups = groupByRequest(chosen);
|
||||||
|
scanItems.forEach((it) => (it.conflict = false));
|
||||||
|
skippedConflicts = 0;
|
||||||
|
const shared = groups.filter((g) => g.length > 1);
|
||||||
|
if (!shared.length) return buildImportGroups(groups);
|
||||||
|
|
||||||
|
const answer = await askAboutSharedRequests(shared);
|
||||||
|
if (answer === "cancel") return null;
|
||||||
|
if (answer === "merge") return buildImportGroups(groups);
|
||||||
|
|
||||||
|
// skip: mark the clashing entries, deselect them, import everything else
|
||||||
|
const skipped = shared.flat();
|
||||||
|
skipped.forEach((it) => {
|
||||||
|
it.conflict = true;
|
||||||
|
it.checked = false;
|
||||||
|
});
|
||||||
|
skippedConflicts = shared.length;
|
||||||
|
return buildImportGroups(groups.filter((g) => g.length === 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
// deepest folder that holds all of the group's entries
|
// deepest folder that holds all of the group's entries
|
||||||
@@ -1184,8 +1227,15 @@ function sharedParent(group) {
|
|||||||
$("#import-btn").addEventListener("click", async () => {
|
$("#import-btn").addEventListener("click", async () => {
|
||||||
const chosen = scanItems.filter((item) => item.checked && item.request_id);
|
const chosen = scanItems.filter((item) => item.checked && item.request_id);
|
||||||
if (!chosen.length) { toast("Nichts ausgewählt", true); return; }
|
if (!chosen.length) { toast("Nichts ausgewählt", true); return; }
|
||||||
const groups = collapseSharedRequests(chosen, { keepGroups: true });
|
const groups = await collapseSharedRequests(chosen);
|
||||||
if (!groups) return;
|
if (!groups) return;
|
||||||
|
if (!groups.length) {
|
||||||
|
$("#import-summary").innerHTML =
|
||||||
|
`<p class="error">Nichts importiert: alle ausgewählten Einträge waren mehreren ` +
|
||||||
|
`Anfragen zugeordnet — Filter „Nur Konflikte" zeigt sie.</p>`;
|
||||||
|
renderImportTable();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const btn = $("#import-btn");
|
const btn = $("#import-btn");
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
@@ -1230,7 +1280,11 @@ $("#import-btn").addEventListener("click", async () => {
|
|||||||
`<p class="${failed.length ? "error" : "ok"}"><strong>` +
|
`<p class="${failed.length ? "error" : "ok"}"><strong>` +
|
||||||
`${allResults.length - failed.length} importiert` +
|
`${allResults.length - failed.length} importiert` +
|
||||||
(failed.length ? `, ${failed.length} Fehler` : "") +
|
(failed.length ? `, ${failed.length} Fehler` : "") +
|
||||||
`</strong></p>`;
|
`</strong></p>` +
|
||||||
|
(skippedConflicts
|
||||||
|
? `<p class="error">${skippedConflicts} Anfrage(n) waren mehreren Einträgen ` +
|
||||||
|
`zugeordnet und wurden übersprungen — Filter „Nur Konflikte" zeigt sie.</p>`
|
||||||
|
: "");
|
||||||
$("#import-results").innerHTML = [...failed, ...allResults.filter((r) => r.ok)]
|
$("#import-results").innerHTML = [...failed, ...allResults.filter((r) => r.ok)]
|
||||||
.map((r) =>
|
.map((r) =>
|
||||||
r.ok
|
r.ok
|
||||||
|
|||||||
@@ -155,6 +155,7 @@
|
|||||||
<option value="all">Alle</option>
|
<option value="all">Alle</option>
|
||||||
<option value="suggested">Nur mit Vorschlag</option>
|
<option value="suggested">Nur mit Vorschlag</option>
|
||||||
<option value="unassigned">Nur ohne Zuordnung</option>
|
<option value="unassigned">Nur ohne Zuordnung</option>
|
||||||
|
<option value="conflict">Nur Konflikte</option>
|
||||||
</select>
|
</select>
|
||||||
<select id="import-sort">
|
<select id="import-sort">
|
||||||
<option value="none">Reihenfolge: Scan</option>
|
<option value="none">Reihenfolge: Scan</option>
|
||||||
@@ -200,6 +201,21 @@
|
|||||||
<div id="import-summary"></div>
|
<div id="import-summary"></div>
|
||||||
<div id="import-results"></div>
|
<div id="import-results"></div>
|
||||||
|
|
||||||
|
<dialog id="conflict-dialog">
|
||||||
|
<h3>Mehrfach zugeordnete Anfragen</h3>
|
||||||
|
<p class="muted" id="conflict-intro"></p>
|
||||||
|
<div id="conflict-list" class="series-list"></div>
|
||||||
|
<div class="row">
|
||||||
|
<button type="button" id="conflict-cancel" class="secondary">Abbrechen</button>
|
||||||
|
<button type="button" id="conflict-merge" class="secondary">
|
||||||
|
Als je ein Hörbuch zusammenfassen
|
||||||
|
</button>
|
||||||
|
<button type="button" id="conflict-skip">
|
||||||
|
Konflikte überspringen, Rest importieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
<dialog id="quick-dialog">
|
<dialog id="quick-dialog">
|
||||||
<h3>Suchen & Anfragen</h3>
|
<h3>Suchen & Anfragen</h3>
|
||||||
<p class="muted mono" id="quick-file"></p>
|
<p class="muted mono" id="quick-file"></p>
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ button:disabled { opacity: 0.6; cursor: default; }
|
|||||||
tr.row-running { background: rgba(90, 140, 220, 0.12); }
|
tr.row-running { background: rgba(90, 140, 220, 0.12); }
|
||||||
tr.row-done { background: rgba(80, 170, 110, 0.12); }
|
tr.row-done { background: rgba(80, 170, 110, 0.12); }
|
||||||
tr.row-failed { background: rgba(200, 90, 90, 0.14); }
|
tr.row-failed { background: rgba(200, 90, 90, 0.14); }
|
||||||
|
tr.row-conflict { background: rgba(200, 90, 90, 0.14); }
|
||||||
|
td.state.conflict { color: #e08585; }
|
||||||
|
#conflict-dialog .series-list label { flex-direction: column; align-items: flex-start; gap: 0.1rem; }
|
||||||
td.state { white-space: nowrap; font-size: 0.85rem; }
|
td.state { white-space: nowrap; font-size: 0.85rem; }
|
||||||
td.state.done { color: var(--ok); }
|
td.state.done { color: var(--ok); }
|
||||||
td.state.failed { color: #e08585; }
|
td.state.failed { color: #e08585; }
|
||||||
|
|||||||
Reference in New Issue
Block a user