add(import): merge multi-part entries into one audiobook
This commit is contained in:
@@ -711,12 +711,14 @@ function renderImportTable() {
|
||||
<td><input type="checkbox" data-check="${i}" ${item.checked ? "checked" : ""}></td>
|
||||
<td class="mono">
|
||||
${esc(item.name)}${item.is_dir ? " 📁" : ""}
|
||||
${item.parts ? `<span class="tag">${item.parts.length} Teile · ${item.files.length} Dateien</span>` : ""}
|
||||
${item.rel_dir ? `<div class="subpath">${esc(item.rel_dir)}/</div>` : ""}
|
||||
</td>
|
||||
<td>${esc(item.media_type)}</td>
|
||||
<td class="row">
|
||||
<select data-select="${i}"><option value="">— überspringen —</option>${opts}</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>
|
||||
${scoreCell(item)}
|
||||
</tr>`;
|
||||
@@ -728,6 +730,9 @@ function renderImportTable() {
|
||||
tbody.querySelectorAll("[data-quick]").forEach((b) =>
|
||||
b.addEventListener("click", () => openQuickDialog(parseInt(b.dataset.quick)))
|
||||
);
|
||||
tbody.querySelectorAll("[data-split]").forEach((b) =>
|
||||
b.addEventListener("click", () => splitMergedItem(parseInt(b.dataset.split)))
|
||||
);
|
||||
tbody.querySelectorAll("[data-check]").forEach((c) =>
|
||||
c.addEventListener("change", () => {
|
||||
scanItems[c.dataset.check].checked = c.checked;
|
||||
@@ -783,6 +788,62 @@ $("#import-deselect-all").addEventListener("click", () => {
|
||||
renderImportTable();
|
||||
});
|
||||
|
||||
// ---- multi-part episodes: import several entries as one audiobook ----
|
||||
// e.g. "100 - Toteninsel Teil 1/2/3", which Audible lists as a single title
|
||||
function commonPrefix(names) {
|
||||
let prefix = names[0];
|
||||
for (const n of names.slice(1)) {
|
||||
let i = 0;
|
||||
while (i < prefix.length && i < n.length && prefix[i] === n[i]) i++;
|
||||
prefix = prefix.slice(0, i);
|
||||
}
|
||||
// drop a dangling part marker: "100 - Toteninsel Teil " -> "100 - Toteninsel"
|
||||
return prefix.replace(/[\s._\-–—:]*(?:teil|part|cd|disc|folge)?[\s._\-–—:]*$/i, "").trim();
|
||||
}
|
||||
|
||||
$("#import-merge").addEventListener("click", () => {
|
||||
const chosen = scanItems.filter((it) => it.checked);
|
||||
if (chosen.length < 2) {
|
||||
toast("Mindestens zwei Einträge auswählen", true);
|
||||
return;
|
||||
}
|
||||
if (new Set(chosen.map((it) => it.media_type)).size > 1) {
|
||||
toast("Nur Einträge desselben Typs lassen sich zusammenfassen", true);
|
||||
return;
|
||||
}
|
||||
const parents = new Set(chosen.map((it) => it.rel_dir));
|
||||
if (parents.size > 1) {
|
||||
toast("Die Einträge liegen in verschiedenen Ordnern", true);
|
||||
return;
|
||||
}
|
||||
const name = commonPrefix(chosen.map((it) => it.name)) || chosen[0].name;
|
||||
const merged = {
|
||||
path: chosen[0].path.slice(0, chosen[0].path.length - chosen[0].name.length - 1) || chosen[0].path,
|
||||
name,
|
||||
rel_dir: chosen[0].rel_dir,
|
||||
media_type: chosen[0].media_type,
|
||||
is_dir: true,
|
||||
files: chosen.flatMap((it) => it.files),
|
||||
checked: true,
|
||||
request_id: chosen.find((it) => it.request_id)?.request_id ?? null,
|
||||
suggested_request_id: chosen.find((it) => it.suggested_request_id)?.suggested_request_id ?? null,
|
||||
score: Math.max(...chosen.map((it) => it.score ?? 0)),
|
||||
parts: chosen,
|
||||
};
|
||||
const first = scanItems.indexOf(chosen[0]);
|
||||
scanItems = scanItems.filter((it) => !chosen.includes(it));
|
||||
scanItems.splice(first, 0, merged);
|
||||
toast(`${chosen.length} Einträge als „${name}" zusammengefasst (${merged.files.length} Dateien)`);
|
||||
renderImportTable();
|
||||
});
|
||||
|
||||
function splitMergedItem(i) {
|
||||
const item = scanItems[i];
|
||||
if (!item.parts) return;
|
||||
scanItems.splice(i, 1, ...item.parts);
|
||||
renderImportTable();
|
||||
}
|
||||
|
||||
// ---- quick request from a scanned file ----
|
||||
let quickItemIndex = null;
|
||||
let quickEpisode = null;
|
||||
|
||||
@@ -162,6 +162,9 @@
|
||||
<button id="import-deselect-all" class="secondary">
|
||||
Alle abwählen
|
||||
</button>
|
||||
<button id="import-merge" class="secondary" title="Mehrteiler: ausgewählte Einträge als ein Hörbuch importieren">
|
||||
Ausgewählte zusammenfassen
|
||||
</button>
|
||||
</div>
|
||||
<div class="table-scroll">
|
||||
<table id="import-table" hidden>
|
||||
|
||||
@@ -54,6 +54,7 @@ button:disabled { opacity: 0.6; cursor: default; }
|
||||
.muted { color: var(--muted); font-size: 0.88rem; }
|
||||
.mono { font-family: ui-monospace, monospace; font-size: 0.85rem; word-break: break-all; }
|
||||
.subpath { color: var(--muted); font-size: 0.75rem; font-family: ui-monospace, monospace; }
|
||||
.tag { display: inline-block; margin-left: 0.4rem; padding: 0.05rem 0.4rem; border-radius: 4px; background: var(--accent); color: #fff; font-size: 0.7rem; font-family: system-ui, sans-serif; vertical-align: 0.1em; }
|
||||
|
||||
/* view modes (Missing/Importiert) */
|
||||
.view-toggle { display: flex; gap: 0; border: 1px solid var(--border); border-radius: 4px; overflow: hidden; margin-left: auto; }
|
||||
|
||||
Reference in New Issue
Block a user