fix(ui/import): read author, series and volume out of the download folder name
Build image / image (push) Successful in 1m38s

This commit is contained in:
Steppenstreuner committed 2026-09-24 13:19:13 +02:00
1 parent 13e2a0693e
commit c8d4d239ec
1 file changed
+106 -17
+106 -17
View File
@@ -1010,8 +1010,8 @@ $("#import-deselect-all").addEventListener("click", () => {
let namesTargets = [];
function nameToRequest(item) {
const { number, rest } = splitEpisodeNumber(item.name);
return { title: cleanFileName(rest) || item.name, volume: number };
const { title, volume } = parseDownloadName(item.name);
return { title, volume };
}
$("#import-from-names").addEventListener("click", () => {
@@ -1023,8 +1023,10 @@ $("#import-from-names").addEventListener("click", () => {
const type = namesTargets[0].media_type;
namesTargets = namesTargets.filter((it) => it.media_type === type);
$("#names-intro").textContent = t("names.intro", { n: namesTargets.length, type });
$("#names-series").value = lastSeries;
$("#names-authors").value = "";
// the folder names usually carry both, e.g. "Autor - Serie 03 - Titel"
const first = parseDownloadName(namesTargets[0].name);
$("#names-series").value = first.series || lastSeries;
$("#names-authors").value = first.authors;
$("#names-library").innerHTML = libOptions(type) || noLibraryOption();
renderNamesPreview();
$("#names-dialog").showModal();
@@ -1176,6 +1178,7 @@ function splitItem(i) {
// ---- quick request from a scanned file ----
let quickItemIndex = null;
let quickEpisode = null;
let quickSeries = ""; // series read from the folder name, if the hit has none
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 = "";
@@ -1200,29 +1203,115 @@ function splitEpisodeNumber(name) {
return { number: null, rest: name };
}
function cleanFileName(name) {
// release noise: Audible treats every word of a query as required, so "6CD",
// "vorgelesen" or a year answers a perfectly good folder name with nothing
const RELEASE_TAG_RE = /\b(?:mp3cd|mp3|m4b|flac|ogg|opus|epub|pdf|azw3|cbz|cbr|retail|unabridged|ungek(?:ue|ü)rzt|gek(?:ue|ü)rzt|vorgelesen|abook|audiobook|h(?:oe|ö)rbuch|\d+\s?cds?|\d+\s?mp|(?:19|20)\d{2}|kompl.*)\b/gi;
// "Grant County 01", "Georgia-Serie 11", "017": a trailing number is the volume
const SERIES_VOLUME_RE = /^(.*?)[\s._-]*(?:reihe|serie)?[\s._-]*(\d{1,3})$/i;
// "Folge 124", "Band 3": a counter word in front of the number is not a series
const COUNTER_WORD_RE = /^(?:folge|folgen|teil|band|nr\.?|nummer|episode|ep|vol|volume|track|cd|disc)$/i;
// a language tag from the release name; uppercase only, so a Spanish "de" stays
const LANG_TAG_RE = /\b(?:DE|EN|GER|ENG|MULTI)\b/g;
// "Karin Slaughter", "J R R Tolkien" - every word capitalised, no digits, so a
// series like "Die drei ???" is not mistaken for an author
const PERSON_RE = /^\p{Lu}[\p{L}.'’-]*(?: \p{Lu}[\p{L}.'’-]*){1,3}$/u;
function stripTags(name) {
return name
.replace(/[._\-\[\]()]+/g, " ")
.replace(/\b(mp3|m4b|flac|epub|pdf|cbz|cbr|retail|unabridged|kompl.*)\b/gi, " ")
.replace(LANG_TAG_RE, " ")
.replace(RELEASE_TAG_RE, " ")
.replace(/\s+/g, " ")
.trim();
}
function splitSeriesVolume(part) {
const m = SERIES_VOLUME_RE.exec(part.trim());
if (!m) return null;
const head = m[1].trim();
return { series: COUNTER_WORD_RE.test(head) ? "" : head, volume: parseInt(m[2], 10) };
}
function cleanFileName(name) {
return stripTags(name.replace(/[._\-\[\]()]+/g, " "));
}
// Download folders follow a few scene conventions: "Autor - Serie 03 - Titel",
// "Serie - 017 - Titel", "Autor - Titel (Serie 7)", "Autor_-_Titel-6CD-2013".
// The series number and the tags must stay out of the search query, but the
// leading segment must stay in it - for "Die drei ???" it is what Audible
// titles the books after.
function parseDownloadName(name) {
let series = "", volume = null, authors = "", lead = "";
let s = name.replace(/_/g, " ").replace(/(\p{L})\.(?=\p{L})/gu, "$1 ");
// "(Georgia 7)" names the series, "(ADAC Motorwelt Edition)" is noise
s = s.replace(/[[(]([^)\]]*)[)\]]/g, (_, inner) => {
const sv = splitSeriesVolume(stripTags(inner));
if (sv) {
series = sv.series || series;
volume = sv.volume;
}
return " ";
});
const parts = stripTags(s)
.split(/\s+[-–—]+\s+/)
.map((p) => p.replace(/^[\s.\-–—]+|[\s.\-–—]+$/g, ""))
.filter(Boolean);
if (parts.length >= 3) {
const sv = splitSeriesVolume(parts[1]);
if (sv) {
volume = sv.volume;
// a named middle part leaves the first one as the author, a bare number
// ("Serie - 017 - Titel") as the series
if (sv.series) {
series = sv.series;
authors = parts[0];
} else {
series = parts[0];
}
lead = parts.splice(0, 2)[0];
}
} else if (parts.length === 2) {
// "Die drei ??? 017 - Titel": series and number share the first part
const sv = splitSeriesVolume(parts[0]);
if (sv && sv.series) {
series = sv.series;
volume = sv.volume;
lead = sv.series;
parts.shift();
}
}
if (!lead && parts.length > 1 && PERSON_RE.test(parts[0])) {
authors = lead = parts.shift();
}
let rest = parts.join(" - ");
if (volume == null) {
const ep = splitEpisodeNumber(rest);
if (ep.number != null) {
volume = ep.number;
rest = ep.rest;
}
}
const title = cleanFileName(rest) || cleanFileName(name) || name;
return { title, volume, series, authors, query: cleanFileName(`${lead} ${rest}`) || title };
}
function openQuickDialog(i) {
quickItemIndex = i;
const item = scanItems[i];
// the number wrecks the Audible search, so it becomes the volume instead
const { number, rest } = splitEpisodeNumber(item.name);
quickEpisode = number;
const parsed = parseDownloadName(item.name);
quickEpisode = parsed.volume;
quickSeries = parsed.series;
$("#quick-file").textContent = (item.rel_dir ? item.rel_dir + "/" : "") + item.name;
$("#quick-episode").textContent = number != null ? t("quick.episodeNote", { n: number }) : "";
$("#quick-q").value = cleanFileName(rest);
$("#quick-episode").textContent =
parsed.volume != null ? t("quick.episodeNote", { n: parsed.volume }) : "";
$("#quick-q").value = parsed.query;
quickPicked = null;
$("#quick-req-filter").value = "";
$("#quick-m-title").value = cleanFileName(rest);
$("#quick-m-authors").value = "";
$("#quick-m-volume").value = number ?? "";
$("#quick-m-series").value = lastSeries;
$("#quick-m-title").value = parsed.title;
$("#quick-m-authors").value = parsed.authors;
$("#quick-m-volume").value = parsed.volume ?? "";
$("#quick-m-series").value = parsed.series || lastSeries;
$("#quick-based-on").hidden = true;
$("#quick-manual-summary").textContent = t("quick.manualSummary");
$("#quick-manual").open = false;
@@ -1370,7 +1459,7 @@ function pickQuickResult(r) {
return requestAndConnect({
title: r.title, authors: r.authors, narrator: r.narrator || "",
external_id: r.external_id, volume: r.volume ?? quickEpisode,
year: r.year, series: r.series, cover_url: r.cover_url,
year: r.year, series: r.series || quickSeries, cover_url: r.cover_url,
});
}
@@ -1380,7 +1469,7 @@ function editBeforeRequest(r) {
quickPicked = r;
$("#quick-m-title").value = r.title || "";
$("#quick-m-authors").value = r.authors || "";
$("#quick-m-series").value = r.series || "";
$("#quick-m-series").value = r.series || quickSeries;
$("#quick-m-volume").value = r.volume ?? quickEpisode ?? "";
$("#quick-based-on").textContent = t("quick.basedOn", { title: r.title });
$("#quick-based-on").hidden = false;