From 6d960eacd1261988b466e7a2b4f4ffd162f07cb3 Mon Sep 17 00:00:00 2001 From: retardgerman <78982850+retardgerman@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:48:27 +0200 Subject: [PATCH] fix(roundup): address pre-release review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - renderFieldGroup: truncate oversized entries and never flush an empty field value, which Discord rejects with a 400 and which would burn the scheduler's weekly failure budget - librarySeeder: build episode series/season keys from SeriesId, not from the episode's own ProviderIds.Tmdb, which never matched a real Series or Season key - libraryPruner: stop pruning "id:" keys — they can originate from item types fetchAllLibraryItems does not enumerate, so the daily scan would drop them and the next poll would re-notify - app.js: clear the initial prune timeout on SIGTERM/SIGINT - i18n: warn once per missing key instead of on every t() call - weeklyRoundup: return an explicit stats object instead of array expandos, rename the `t` shadow in groupItems, escape "|", drop the episode debug dump - de/sv: add missing reseed_library keys - CHANGELOG: correct release date, drop the stale WEEKLY_ROUNDUP_LAST_POSTED_AT description, add role mention and the undici/axios/body-parser/joi security bumps --- CHANGELOG.md | 9 ++++- app.js | 5 ++- bot/weeklyRoundup.js | 84 +++++++++++++++------------------------ jellyfin/libraryPruner.js | 7 ++-- jellyfin/librarySeeder.js | 6 +-- locales/de.json | 2 + locales/sv.json | 2 + utils/i18n.js | 10 ++++- 8 files changed, 64 insertions(+), 61 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a3e0d0..a4f52b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,13 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- -## [1.5.6] - 2026-06-14 +## [1.5.6] - 2026-08-07 ### ✨ Added - **Separate overview toggle for episodes**: The embed overview setting is now split into two independent options -- one for movies and series, one for episodes. Episode summaries can be disabled independently to avoid spoilers for shows you haven't caught up on. Both options are on by default. Configurable via the dashboard under "Embed Options". The previous `EMBED_SHOW_OVERVIEW` setting has been replaced and is migrated automatically to both new options on first start after upgrading; no action needed. -- **Weekly Roundup**: Optional scheduled Discord post that summarizes new Jellyfin content from the last 7 days. Disabled by default. Configurable via the dashboard (channel, weekday, hour, embed color). The roundup groups items by library and collapses episodes of the same series into one line (e.g. _"My Show — Seasons 1 & 2 (12 episodes)"_). Item titles link directly to Jellyfin. A hourly scheduler tick with a persisted `WEEKLY_ROUNDUP_LAST_POSTED_AT` timestamp makes the post idempotent across Docker restarts. Sonarr/Radarr quality upgrades are filtered out via a stable-identity first-seen map (`config/dedup-roundup-first-seen.json`) so a re-imported file does not show up as "new". +- **Weekly Roundup**: Optional scheduled Discord post that summarizes new Jellyfin content from the last 7 days. Disabled by default. Configurable via the dashboard (channel, weekday, hour, embed color). The roundup groups items by library and collapses episodes of the same series into one line (e.g. _"My Show — Seasons 1 & 2 (12 episodes)"_). Item titles link directly to Jellyfin. An hourly scheduler tick plus a persisted post timestamp (`config/dedup-roundup-state.json`) makes the post idempotent across Docker restarts. Sonarr/Radarr quality upgrades are filtered out via a stable-identity first-seen map (`config/dedup-roundup-first-seen.json`) so a re-imported file does not show up as "new". + +- **Weekly Roundup role mention**: Optionally ping a Discord role when the roundup posts. Pick the role from a dropdown in the dashboard; leave it on "No role mention" to post silently. The test button never pings the role. - **Library seed scan**: On first boot, Anchorr now scans your entire Jellyfin library and records everything that already exists, so pre-existing content never triggers a "new item" Discord notification. - **Daily prune scan**: A background job runs once per day to remove records for items that have been deleted from Jellyfin, keeping internal state from growing unbounded. @@ -27,6 +29,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 🔒 Security - **form-data bumped to 4.0.6** (GHSA-hmw2-7cc7-3qxx): Resolves a prototype pollution vulnerability in a transitive dependency. +- **undici pinned to ^6.26.1** (GHSA-p88m-4jfj-68fv, GHSA-vxpw-j846-p89q, GHSA-35p6-xmwp-9g52, GHSA-g8m3-5g58-fq7m): Resolves four vulnerabilities in a transitive dependency. +- **axios bumped to 1.19.0**: Resolves high-severity CVEs in the HTTP client used for all Jellyfin, Jellyseerr, Radarr, Sonarr, TMDB and OMDb calls. +- **body-parser pinned to ^1.20.6** and **joi bumped to 18.2.1**: Clears the remaining `npm audit` findings. --- diff --git a/app.js b/app.js index fc9f630..9afccdf 100644 --- a/app.js +++ b/app.js @@ -1075,6 +1075,7 @@ logger.info("Web server configured successfully"); // This single `app.listen` call handles both modes. let server; let libraryPruneTimer; +let libraryPruneInitialTimer; function startServer() { // Check volume configuration early @@ -1167,7 +1168,7 @@ function startServer() { // that restarts more often than that (e.g. on every config save) would // otherwise never run a prune cycle at all. Run one shortly after boot too // so seeded/pruned keys get re-asserted regardless of restart frequency. - setTimeout(() => { + libraryPruneInitialTimer = setTimeout(() => { pruneLibrary().catch((err) => logger.error(`libraryPruner: unexpected rejection in initial prune (${err?.message || err})`) ); @@ -1198,6 +1199,7 @@ function startServer() { // Keep the process alive process.on("SIGTERM", () => { clearInterval(libraryPruneTimer); + clearTimeout(libraryPruneInitialTimer); logger.info("SIGTERM signal received: closing HTTP server"); server.close(() => { logger.info("HTTP server closed"); @@ -1207,6 +1209,7 @@ process.on("SIGTERM", () => { process.on("SIGINT", () => { clearInterval(libraryPruneTimer); + clearTimeout(libraryPruneInitialTimer); logger.info("SIGINT signal received: closing HTTP server"); server.close(() => { logger.info("HTTP server closed"); diff --git a/bot/weeklyRoundup.js b/bot/weeklyRoundup.js index 423cda4..4197be2 100644 --- a/bot/weeklyRoundup.js +++ b/bot/weeklyRoundup.js @@ -84,27 +84,11 @@ async function fetchWindowItems() { `Weekly Roundup: queried ${configuredIds.length} configured libraries since ${cutoff}, got ${totalRaw} items (${filtered.length} after dedupe)` ); - // Diagnostic: dump the raw identity fields for each episode so we can see - // why dedup might fail (missing IndexNumber, varying Name, multiple item - // ids for the same episode, ...). Debug-level — only useful when actively - // chasing a dedup mismatch; noise during normal operation. - const episodes = filtered.filter((it) => it.Type === "Episode"); - if (episodes.length > 0) { - const dump = episodes - .slice(0, 30) - .map( - (e) => - `{id:${e.Id}, series:"${e.SeriesName}", S${e.ParentIndexNumber}E${e.IndexNumber}${e.IndexNumberEnd != null ? `-${e.IndexNumberEnd}` : ""}, name:"${e.Name}", created:${e.DateCreated}}` - ) - .join("\n "); - logger.debug( - `Weekly Roundup: episode raw fields (first 30 of ${episodes.length}):\n ${dump}` - ); - } - - filtered.rawCount = totalRaw; - filtered.allowedLibraryCount = configuredIds.length; - return filtered; + return { + items: filtered, + rawCount: totalRaw, + allowedLibraryCount: configuredIds.length, + }; } /** @@ -234,9 +218,9 @@ function groupItems(items) { // naturally and the bare-title row (-1) sorts first. const showOrder = new Map(); for (const e of entriesOut) { - const t = e.createdAt.getTime(); - if (!showOrder.has(e.showKey) || t > showOrder.get(e.showKey)) { - showOrder.set(e.showKey, t); + const ts = e.createdAt.getTime(); + if (!showOrder.has(e.showKey) || ts > showOrder.get(e.showKey)) { + showOrder.set(e.showKey, ts); } } entriesOut.sort((a, b) => { @@ -339,7 +323,7 @@ function escapeMd(s) { // Parens are not markdown control chars inside [label] — escaping them // produces literal "\(2026\)" in the rendered link. Only escape the // chars that Discord actually treats as markdown inside link labels. - return String(s).replace(/[\r\n]/g, " ").replace(/([\[\]\\*_~`])/g, "\\$1"); + return String(s).replace(/[\r\n]/g, " ").replace(/([\[\]\\*_~`|])/g, "\\$1"); } export async function sendWeeklyRoundup(client, channelId, now, options = {}) { @@ -376,9 +360,9 @@ export async function sendWeeklyRoundup(client, channelId, now, options = {}) { throw new Error(msg); } - let items; + let fetched; try { - items = await fetchWindowItems(); + fetched = await fetchWindowItems(); } catch (err) { logger.error(`${logPrefix}: failed to fetch items: ${err?.message}`); if (isTest) { @@ -395,12 +379,12 @@ export async function sendWeeklyRoundup(client, channelId, now, options = {}) { // identity (TMDB / SeriesId+S/E) matches a prior record. const cutoffMs = now.getTime() - WINDOW_MS; const installedAt = getInstalledAt(now.getTime()); - const beforeFilter = items.length; + const beforeFilter = fetched.items.length; let droppedPreInstall = 0; let droppedOldDateCreated = 0; let droppedNoDateCreated = 0; let droppedAlreadySeen = 0; - const fresh = items.filter((item) => { + const items = fetched.items.filter((item) => { const created = item.DateCreated ? new Date(item.DateCreated).getTime() : NaN; if (!Number.isFinite(created)) { // Safer default for a "what's new this week" digest: an item without @@ -425,24 +409,19 @@ export async function sendWeeklyRoundup(client, channelId, now, options = {}) { return true; }); logger.debug( - `${logPrefix}: filtered ${beforeFilter} → ${fresh.length} items (no DateCreated: ${droppedNoDateCreated}, pre-install: ${droppedPreInstall}, old DateCreated: ${droppedOldDateCreated}, already-seen: ${droppedAlreadySeen})` + `${logPrefix}: filtered ${beforeFilter} → ${items.length} items (no DateCreated: ${droppedNoDateCreated}, pre-install: ${droppedPreInstall}, old DateCreated: ${droppedOldDateCreated}, already-seen: ${droppedAlreadySeen})` ); - // Preserve the diagnostic counters from fetchWindowItems on the filtered - // array (filter() drops these expando properties). - fresh.rawCount = items.rawCount; - fresh.allowedLibraryCount = items.allowedLibraryCount; - fresh.alreadySeenCount = beforeFilter - fresh.length; - items = fresh; - if (items.alreadySeenCount > 0) { + const alreadySeenCount = beforeFilter - items.length; + if (alreadySeenCount > 0) { logger.info( - `${logPrefix}: filtered ${items.alreadySeenCount} of ${beforeFilter} items as already-seen (Sonarr/Radarr upgrade or older import)` + `${logPrefix}: filtered ${alreadySeenCount} of ${beforeFilter} items as already-seen (Sonarr/Radarr upgrade or older import)` ); } if (items.length === 0) { - const rawCount = items.rawCount ?? 0; - const allowedCount = items.allowedLibraryCount ?? 0; - const alreadySeen = items.alreadySeenCount ?? 0; + const rawCount = fetched.rawCount; + const allowedCount = fetched.allowedLibraryCount; + const alreadySeen = alreadySeenCount; let diag; if (allowedCount === 0) { diag = "No notification libraries configured. Add libraries under Jellyfin notifications in the dashboard."; @@ -454,13 +433,8 @@ export async function sendWeeklyRoundup(client, channelId, now, options = {}) { diag = "Jellyfin returned no new items (Movie/Series/Season/Episode) in the past 7 days."; } if (isTest) throw new Error(diag); - // warn (not info): "no items" with no configured libraries or a 0-of-N - // mismatch is the most common silent-fail symptom users mistake for a - // broken feature. Surfacing it loudly in the logs lets ops debug without - // turning on debug logging. - // Misconfig (no libraries) or rawCount-but-not-in-config is a silent-fail - // symptom users mistake for a broken feature → warn. "Genuinely empty - // week" and "everything was an upgrade" are normal → info. + // Misconfig is a silent-fail symptom users mistake for a broken feature → + // warn. A genuinely empty week is normal → info. if (allowedCount === 0 || (rawCount > 0 && alreadySeen === 0)) { logger.warn(`${logPrefix}: skipping post — ${diag}`); } else { @@ -597,11 +571,19 @@ function renderFieldGroup(name, entries) { let currentName = name; let value = ""; - for (const entry of entries) { + for (const rawEntry of entries) { + // Discord rejects fields with an empty value, so never let an oversized + // entry flush an empty one. + const entry = + rawEntry.length > FIELD_VALUE_BUDGET + ? rawEntry.slice(0, FIELD_VALUE_BUDGET - 1) + "…" + : rawEntry; const next = (value ? "\n" : "") + entry; if (value.length + next.length > FIELD_VALUE_BUDGET) { - fields.push({ name: currentName, value }); - currentName = name + " " + t("roundup.field_continued"); + if (value) { + fields.push({ name: currentName, value }); + currentName = name + " " + t("roundup.field_continued"); + } value = entry; } else { value += next; diff --git a/jellyfin/libraryPruner.js b/jellyfin/libraryPruner.js index ab01d1a..0391cc9 100644 --- a/jellyfin/libraryPruner.js +++ b/jellyfin/libraryPruner.js @@ -60,11 +60,12 @@ export async function pruneLibrary() { deduplicator.store.set(key, true); } + // Deliberately not pruning "id:" keys: they can come from item types + // fetchAllLibraryItems does not enumerate, so they would be removed here + // and re-notified on the next poll. They expire via TTL instead. const removed = deduplicator.store.prune( (key) => - (key.startsWith("movie:") || - key.startsWith("series:") || - key.startsWith("id:")) && + (key.startsWith("movie:") || key.startsWith("series:")) && !currentKeys.has(key) ); diff --git a/jellyfin/librarySeeder.js b/jellyfin/librarySeeder.js index 5782f5a..1f870d9 100644 --- a/jellyfin/librarySeeder.js +++ b/jellyfin/librarySeeder.js @@ -30,9 +30,9 @@ export function deriveSeedKeys(item) { if (itemKey) keys.push(itemKey); if (item.Type === "Episode") { - const seriesKeyPart = item.ProviderIds?.Tmdb - ? `tmdb:${item.ProviderIds.Tmdb}` - : item.SeriesId + // An episode's ProviderIds.Tmdb is the *episode's* TMDB id, not the + // series' — using it here would build series keys that match nothing. + const seriesKeyPart = item.SeriesId ? `id:${item.SeriesId}` : item.SeriesName ? `name:${item.SeriesName}` diff --git a/locales/de.json b/locales/de.json index 3a3b5d0..137bb40 100644 --- a/locales/de.json +++ b/locales/de.json @@ -127,6 +127,8 @@ "jellyfin_api_key_help": "API-Schlüssel für deinen Jellyfin-Server. Du kannst einen im Jellyfin Dashboard → Administration → API-Schlüssel erstellen. Dies ist erforderlich für das Laden von Bibliotheken und erweiterte Funktionen.", "test_endpoint": "Endpunkt testen", "test_endpoint_help": "Das erfolgreiche Testen des Endpunkts füllt automatisch das Feld Jellyfin Server-ID aus.", + "reseed_library": "Bibliothek neu einlesen", + "reseed_library_help": "Scannt deine komplette Jellyfin-Bibliothek erneut und markiert alle vorhandenen Einträge als \"bereits bekannt\", sodass sie keine Discord-Benachrichtigungen auslösen. Nutze das, wenn du deine Bibliothek umstrukturiert hast oder alte Einträge fälschlich als neu angekündigt werden.", "jellyfin_server_id": "Jellyfin Server-ID", "jellyfin_server_id_help": "Verwendet für die Weiterleitung zum korrekten Pfad deiner Jellyfin-Server-Medienseite beim Verwenden des \"Jetzt ansehen\"-Buttons. Du findest diese in deiner Jellyfin-URL, wenn du die Seite eines Films oder einer Serie aus deiner Bibliothek betrachtest.", "guild_id": "Server ID", diff --git a/locales/sv.json b/locales/sv.json index f5b95e3..010f38c 100644 --- a/locales/sv.json +++ b/locales/sv.json @@ -128,6 +128,8 @@ "jellyfin_api_key_help": "Ange Jellyfin API-nyckel", "test_endpoint": "Testa endpoint", "test_endpoint_help": "Kontrollera Jellyfin-endpoint", + "reseed_library": "Läs in biblioteket på nytt", + "reseed_library_help": "Skannar om hela ditt Jellyfin-bibliotek och markerar allt befintligt innehåll som \"redan känt\", så att det inte utlöser Discord-aviseringar. Använd detta om du har omorganiserat biblioteket eller om gamla objekt felaktigt annonseras som nya.", "jellyfin_server_id": "Jellyfin server-ID", "jellyfin_server_id_help": "Ange server-ID för Jellyfin", "notification_testing_title": "Notifieringstest", diff --git a/utils/i18n.js b/utils/i18n.js index 4051898..84a132c 100644 --- a/utils/i18n.js +++ b/utils/i18n.js @@ -19,6 +19,7 @@ const LANG_CODE_RE = /^[a-zA-Z]{2,3}(?:[_-][a-zA-Z0-9]{2,8})?$/; let translations = null; let englishFallback = null; let loadedLang = null; +const warnedMissingKeys = new Set(); function safeLang(raw) { if (!raw || typeof raw !== "string") return FALLBACK_LANG; @@ -93,7 +94,13 @@ export function t(key, vars) { if (typeof value !== "string") { value = lookup(englishFallback, key); if (typeof value !== "string") return key; - logger.warn(`[i18n] Key '${key}' missing in '${loadedLang}', falling back to '${FALLBACK_LANG}'.`); + // Warn once per key — t() runs per rendered string, so an untranslated + // locale would otherwise flood the log on every roundup. + const warnKey = `${loadedLang}:${key}`; + if (!warnedMissingKeys.has(warnKey)) { + warnedMissingKeys.add(warnKey); + logger.warn(`[i18n] Key '${key}' missing in '${loadedLang}', falling back to '${FALLBACK_LANG}'.`); + } } return interpolate(value, vars); } @@ -102,4 +109,5 @@ export function resetI18nCache() { translations = null; englishFallback = null; loadedLang = null; + warnedMissingKeys.clear(); }