fix(import): stop several entries from claiming one request

This commit is contained in:
Steppenstreuner
2026-08-28 21:01:07 +02:00
parent a816dcb27a
commit 60b9e44a11
6 changed files with 280 additions and 52 deletions
+60 -14
View File
@@ -621,6 +621,7 @@ $("#detail-form").addEventListener("submit", async (e) => {
// ---- import ----
let scanItems = [];
let missingReqs = [];
let importedReqs = [];
let viewIdx = []; // indices into scanItems after filter/sort
let importPage = 0;
const IMPORT_PAGE_SIZE = 25;
@@ -631,9 +632,10 @@ $("#scan-btn").addEventListener("click", async () => {
$("#scan-info").innerHTML = '<span class="spinner"></span> Scanne…';
try {
const split = $("#split-dirs").checked;
const [scan, reqs] = await Promise.all([
const [scan, reqs, done] = await Promise.all([
api("/api/import/scan?split_dirs=" + split),
api("/api/requests?status=missing"),
api("/api/requests?status=imported"),
]);
scanItems = scan.items;
// selection state lives here, not in the DOM, so it survives paging/filtering
@@ -642,6 +644,7 @@ $("#scan-btn").addEventListener("click", async () => {
item.checked = !!item.suggested_request_id;
});
missingReqs = reqs;
importedReqs = done;
importPage = 0;
$("#scan-info").textContent = `${scan.items.length} Kandidat(en) in ${scan.download_dir}`;
$("#import-toolbar").hidden = scanItems.length === 0;
@@ -699,13 +702,18 @@ function renderImportTable() {
tbody.innerHTML = pageIdx
.map((i) => {
const item = scanItems[i];
const option = (r, suffix = "") =>
`<option value="${r.id}" ${r.id === item.request_id ? "selected" : ""}>
${esc(r.title)}${esc(r.authors)} (${esc(r.library_name)})${suffix}</option>`;
const opts = missingReqs
.filter((r) => r.media_type === item.media_type)
.map(
(r) =>
`<option value="${r.id}" ${r.id === item.request_id ? "selected" : ""}>
${esc(r.title)}${esc(r.authors)} (${esc(r.library_name)})</option>`
)
.map((r) => option(r))
.join("");
// already imported audiobooks can take further parts (a late CD, or one
// that failed while its siblings went through)
const appendOpts = importedReqs
.filter((r) => r.media_type === item.media_type && r.imported_path)
.map((r) => option(r, " ↩︎"))
.join("");
return `<tr>
<td><input type="checkbox" data-check="${i}" ${item.checked ? "checked" : ""}></td>
@@ -716,7 +724,9 @@ function renderImportTable() {
</td>
<td>${esc(item.media_type)}</td>
<td class="row">
<select data-select="${i}"><option value="">— überspringen —</option>${opts}</select>
<select data-select="${i}"><option value="">— überspringen —</option>${opts}
${appendOpts ? `<optgroup label="Bereits importiert — Teile anhängen">${appendOpts}</optgroup>` : ""}
</select>
<button class="secondary" data-quick="${i}" title="Metadaten suchen und Anfrage direkt verbinden">🔍</button>
${item.parts ? `<button class="secondary" data-split="${i}" title="Zusammenfassung wieder auflösen">✂️</button>` : ""}
</td>
@@ -1002,14 +1012,50 @@ async function pickQuickResult(r) {
// ---- import execution (batched, with progress) ----
const IMPORT_BATCH_SIZE = 20;
// several entries on one request means one audiobook split across folders:
// importing them one by one only imports the first and fails the rest
function collapseSharedRequests(chosen) {
const byRequest = new Map();
for (const item of chosen) {
const group = byRequest.get(item.request_id);
if (group) group.push(item);
else byRequest.set(item.request_id, [item]);
}
const shared = [...byRequest.values()].filter((g) => g.length > 1);
if (shared.length) {
const names = shared
.map((g) => `${g.map((it) => it.name).join(" + ")}`)
.join("\n");
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;
}
return [...byRequest.values()].map((group) => ({
path: group.length > 1 ? sharedParent(group) : group[0].path,
is_dir: group.length > 1 ? true : group[0].is_dir,
files: group.flatMap((it) => it.files),
request_id: group[0].request_id,
append: importedReqs.some((r) => r.id === group[0].request_id),
}));
}
// deepest folder that holds all of the group's entries
function sharedParent(group) {
const parts = group.map((it) => it.path.split("/"));
const first = parts[0];
let i = 0;
while (i < first.length - 1 && parts.every((p) => p[i] === first[i])) i++;
return first.slice(0, i).join("/") || group[0].path;
}
$("#import-btn").addEventListener("click", async () => {
const items = scanItems
.filter((item) => item.checked && item.request_id)
.map((item) => ({
path: item.path, is_dir: item.is_dir, files: item.files,
request_id: item.request_id,
}));
if (!items.length) { toast("Nichts ausgewählt", true); return; }
const chosen = scanItems.filter((item) => item.checked && item.request_id);
if (!chosen.length) { toast("Nichts ausgewählt", true); return; }
const items = collapseSharedRequests(chosen);
if (!items) return;
const btn = $("#import-btn");
btn.disabled = true;
const allResults = [];