diff --git a/static/app.js b/static/app.js index 9499c28..ffd80d8 100644 --- a/static/app.js +++ b/static/app.js @@ -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;