Skip to content

Commit ac5115c

Browse files
authored
Fix pre-release review findings for v1.5.6
2 parents 9b7d129 + 6d960ea commit ac5115c

8 files changed

Lines changed: 64 additions & 61 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
---
99

10-
## [1.5.6] - 2026-06-14
10+
## [1.5.6] - 2026-08-07
1111

1212
### ✨ Added
1313

1414
- **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.
1515

16-
- **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".
16+
- **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".
17+
18+
- **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.
1719

1820
- **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.
1921
- **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
2729
### 🔒 Security
2830

2931
- **form-data bumped to 4.0.6** (GHSA-hmw2-7cc7-3qxx): Resolves a prototype pollution vulnerability in a transitive dependency.
32+
- **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.
33+
- **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.
34+
- **body-parser pinned to ^1.20.6** and **joi bumped to 18.2.1**: Clears the remaining `npm audit` findings.
3035

3136
---
3237

app.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1075,6 +1075,7 @@ logger.info("Web server configured successfully");
10751075
// This single `app.listen` call handles both modes.
10761076
let server;
10771077
let libraryPruneTimer;
1078+
let libraryPruneInitialTimer;
10781079

10791080
function startServer() {
10801081
// Check volume configuration early
@@ -1167,7 +1168,7 @@ function startServer() {
11671168
// that restarts more often than that (e.g. on every config save) would
11681169
// otherwise never run a prune cycle at all. Run one shortly after boot too
11691170
// so seeded/pruned keys get re-asserted regardless of restart frequency.
1170-
setTimeout(() => {
1171+
libraryPruneInitialTimer = setTimeout(() => {
11711172
pruneLibrary().catch((err) =>
11721173
logger.error(`libraryPruner: unexpected rejection in initial prune (${err?.message || err})`)
11731174
);
@@ -1198,6 +1199,7 @@ function startServer() {
11981199
// Keep the process alive
11991200
process.on("SIGTERM", () => {
12001201
clearInterval(libraryPruneTimer);
1202+
clearTimeout(libraryPruneInitialTimer);
12011203
logger.info("SIGTERM signal received: closing HTTP server");
12021204
server.close(() => {
12031205
logger.info("HTTP server closed");
@@ -1207,6 +1209,7 @@ process.on("SIGTERM", () => {
12071209

12081210
process.on("SIGINT", () => {
12091211
clearInterval(libraryPruneTimer);
1212+
clearTimeout(libraryPruneInitialTimer);
12101213
logger.info("SIGINT signal received: closing HTTP server");
12111214
server.close(() => {
12121215
logger.info("HTTP server closed");

bot/weeklyRoundup.js

Lines changed: 33 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -84,27 +84,11 @@ async function fetchWindowItems() {
8484
`Weekly Roundup: queried ${configuredIds.length} configured libraries since ${cutoff}, got ${totalRaw} items (${filtered.length} after dedupe)`
8585
);
8686

87-
// Diagnostic: dump the raw identity fields for each episode so we can see
88-
// why dedup might fail (missing IndexNumber, varying Name, multiple item
89-
// ids for the same episode, ...). Debug-level — only useful when actively
90-
// chasing a dedup mismatch; noise during normal operation.
91-
const episodes = filtered.filter((it) => it.Type === "Episode");
92-
if (episodes.length > 0) {
93-
const dump = episodes
94-
.slice(0, 30)
95-
.map(
96-
(e) =>
97-
`{id:${e.Id}, series:"${e.SeriesName}", S${e.ParentIndexNumber}E${e.IndexNumber}${e.IndexNumberEnd != null ? `-${e.IndexNumberEnd}` : ""}, name:"${e.Name}", created:${e.DateCreated}}`
98-
)
99-
.join("\n ");
100-
logger.debug(
101-
`Weekly Roundup: episode raw fields (first 30 of ${episodes.length}):\n ${dump}`
102-
);
103-
}
104-
105-
filtered.rawCount = totalRaw;
106-
filtered.allowedLibraryCount = configuredIds.length;
107-
return filtered;
87+
return {
88+
items: filtered,
89+
rawCount: totalRaw,
90+
allowedLibraryCount: configuredIds.length,
91+
};
10892
}
10993

11094
/**
@@ -234,9 +218,9 @@ function groupItems(items) {
234218
// naturally and the bare-title row (-1) sorts first.
235219
const showOrder = new Map();
236220
for (const e of entriesOut) {
237-
const t = e.createdAt.getTime();
238-
if (!showOrder.has(e.showKey) || t > showOrder.get(e.showKey)) {
239-
showOrder.set(e.showKey, t);
221+
const ts = e.createdAt.getTime();
222+
if (!showOrder.has(e.showKey) || ts > showOrder.get(e.showKey)) {
223+
showOrder.set(e.showKey, ts);
240224
}
241225
}
242226
entriesOut.sort((a, b) => {
@@ -339,7 +323,7 @@ function escapeMd(s) {
339323
// Parens are not markdown control chars inside [label] — escaping them
340324
// produces literal "\(2026\)" in the rendered link. Only escape the
341325
// chars that Discord actually treats as markdown inside link labels.
342-
return String(s).replace(/[\r\n]/g, " ").replace(/([\[\]\\*_~`])/g, "\\$1");
326+
return String(s).replace(/[\r\n]/g, " ").replace(/([\[\]\\*_~`|])/g, "\\$1");
343327
}
344328

345329
export async function sendWeeklyRoundup(client, channelId, now, options = {}) {
@@ -376,9 +360,9 @@ export async function sendWeeklyRoundup(client, channelId, now, options = {}) {
376360
throw new Error(msg);
377361
}
378362

379-
let items;
363+
let fetched;
380364
try {
381-
items = await fetchWindowItems();
365+
fetched = await fetchWindowItems();
382366
} catch (err) {
383367
logger.error(`${logPrefix}: failed to fetch items: ${err?.message}`);
384368
if (isTest) {
@@ -395,12 +379,12 @@ export async function sendWeeklyRoundup(client, channelId, now, options = {}) {
395379
// identity (TMDB / SeriesId+S/E) matches a prior record.
396380
const cutoffMs = now.getTime() - WINDOW_MS;
397381
const installedAt = getInstalledAt(now.getTime());
398-
const beforeFilter = items.length;
382+
const beforeFilter = fetched.items.length;
399383
let droppedPreInstall = 0;
400384
let droppedOldDateCreated = 0;
401385
let droppedNoDateCreated = 0;
402386
let droppedAlreadySeen = 0;
403-
const fresh = items.filter((item) => {
387+
const items = fetched.items.filter((item) => {
404388
const created = item.DateCreated ? new Date(item.DateCreated).getTime() : NaN;
405389
if (!Number.isFinite(created)) {
406390
// 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 = {}) {
425409
return true;
426410
});
427411
logger.debug(
428-
`${logPrefix}: filtered ${beforeFilter}${fresh.length} items (no DateCreated: ${droppedNoDateCreated}, pre-install: ${droppedPreInstall}, old DateCreated: ${droppedOldDateCreated}, already-seen: ${droppedAlreadySeen})`
412+
`${logPrefix}: filtered ${beforeFilter}${items.length} items (no DateCreated: ${droppedNoDateCreated}, pre-install: ${droppedPreInstall}, old DateCreated: ${droppedOldDateCreated}, already-seen: ${droppedAlreadySeen})`
429413
);
430-
// Preserve the diagnostic counters from fetchWindowItems on the filtered
431-
// array (filter() drops these expando properties).
432-
fresh.rawCount = items.rawCount;
433-
fresh.allowedLibraryCount = items.allowedLibraryCount;
434-
fresh.alreadySeenCount = beforeFilter - fresh.length;
435-
items = fresh;
436-
if (items.alreadySeenCount > 0) {
414+
const alreadySeenCount = beforeFilter - items.length;
415+
if (alreadySeenCount > 0) {
437416
logger.info(
438-
`${logPrefix}: filtered ${items.alreadySeenCount} of ${beforeFilter} items as already-seen (Sonarr/Radarr upgrade or older import)`
417+
`${logPrefix}: filtered ${alreadySeenCount} of ${beforeFilter} items as already-seen (Sonarr/Radarr upgrade or older import)`
439418
);
440419
}
441420

442421
if (items.length === 0) {
443-
const rawCount = items.rawCount ?? 0;
444-
const allowedCount = items.allowedLibraryCount ?? 0;
445-
const alreadySeen = items.alreadySeenCount ?? 0;
422+
const rawCount = fetched.rawCount;
423+
const allowedCount = fetched.allowedLibraryCount;
424+
const alreadySeen = alreadySeenCount;
446425
let diag;
447426
if (allowedCount === 0) {
448427
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 = {}) {
454433
diag = "Jellyfin returned no new items (Movie/Series/Season/Episode) in the past 7 days.";
455434
}
456435
if (isTest) throw new Error(diag);
457-
// warn (not info): "no items" with no configured libraries or a 0-of-N
458-
// mismatch is the most common silent-fail symptom users mistake for a
459-
// broken feature. Surfacing it loudly in the logs lets ops debug without
460-
// turning on debug logging.
461-
// Misconfig (no libraries) or rawCount-but-not-in-config is a silent-fail
462-
// symptom users mistake for a broken feature → warn. "Genuinely empty
463-
// week" and "everything was an upgrade" are normal → info.
436+
// Misconfig is a silent-fail symptom users mistake for a broken feature →
437+
// warn. A genuinely empty week is normal → info.
464438
if (allowedCount === 0 || (rawCount > 0 && alreadySeen === 0)) {
465439
logger.warn(`${logPrefix}: skipping post — ${diag}`);
466440
} else {
@@ -597,11 +571,19 @@ function renderFieldGroup(name, entries) {
597571
let currentName = name;
598572
let value = "";
599573

600-
for (const entry of entries) {
574+
for (const rawEntry of entries) {
575+
// Discord rejects fields with an empty value, so never let an oversized
576+
// entry flush an empty one.
577+
const entry =
578+
rawEntry.length > FIELD_VALUE_BUDGET
579+
? rawEntry.slice(0, FIELD_VALUE_BUDGET - 1) + "…"
580+
: rawEntry;
601581
const next = (value ? "\n" : "") + entry;
602582
if (value.length + next.length > FIELD_VALUE_BUDGET) {
603-
fields.push({ name: currentName, value });
604-
currentName = name + " " + t("roundup.field_continued");
583+
if (value) {
584+
fields.push({ name: currentName, value });
585+
currentName = name + " " + t("roundup.field_continued");
586+
}
605587
value = entry;
606588
} else {
607589
value += next;

jellyfin/libraryPruner.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,11 +60,12 @@ export async function pruneLibrary() {
6060
deduplicator.store.set(key, true);
6161
}
6262

63+
// Deliberately not pruning "id:" keys: they can come from item types
64+
// fetchAllLibraryItems does not enumerate, so they would be removed here
65+
// and re-notified on the next poll. They expire via TTL instead.
6366
const removed = deduplicator.store.prune(
6467
(key) =>
65-
(key.startsWith("movie:") ||
66-
key.startsWith("series:") ||
67-
key.startsWith("id:")) &&
68+
(key.startsWith("movie:") || key.startsWith("series:")) &&
6869
!currentKeys.has(key)
6970
);
7071

jellyfin/librarySeeder.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,9 @@ export function deriveSeedKeys(item) {
3030
if (itemKey) keys.push(itemKey);
3131

3232
if (item.Type === "Episode") {
33-
const seriesKeyPart = item.ProviderIds?.Tmdb
34-
? `tmdb:${item.ProviderIds.Tmdb}`
35-
: item.SeriesId
33+
// An episode's ProviderIds.Tmdb is the *episode's* TMDB id, not the
34+
// series' — using it here would build series keys that match nothing.
35+
const seriesKeyPart = item.SeriesId
3636
? `id:${item.SeriesId}`
3737
: item.SeriesName
3838
? `name:${item.SeriesName}`

locales/de.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,8 @@
127127
"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.",
128128
"test_endpoint": "Endpunkt testen",
129129
"test_endpoint_help": "Das erfolgreiche Testen des Endpunkts füllt automatisch das Feld Jellyfin Server-ID aus.",
130+
"reseed_library": "Bibliothek neu einlesen",
131+
"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.",
130132
"jellyfin_server_id": "Jellyfin Server-ID",
131133
"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.",
132134
"guild_id": "Server ID",

locales/sv.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,8 @@
128128
"jellyfin_api_key_help": "Ange Jellyfin API-nyckel",
129129
"test_endpoint": "Testa endpoint",
130130
"test_endpoint_help": "Kontrollera Jellyfin-endpoint",
131+
"reseed_library": "Läs in biblioteket på nytt",
132+
"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.",
131133
"jellyfin_server_id": "Jellyfin server-ID",
132134
"jellyfin_server_id_help": "Ange server-ID för Jellyfin",
133135
"notification_testing_title": "Notifieringstest",

utils/i18n.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const LANG_CODE_RE = /^[a-zA-Z]{2,3}(?:[_-][a-zA-Z0-9]{2,8})?$/;
1919
let translations = null;
2020
let englishFallback = null;
2121
let loadedLang = null;
22+
const warnedMissingKeys = new Set();
2223

2324
function safeLang(raw) {
2425
if (!raw || typeof raw !== "string") return FALLBACK_LANG;
@@ -93,7 +94,13 @@ export function t(key, vars) {
9394
if (typeof value !== "string") {
9495
value = lookup(englishFallback, key);
9596
if (typeof value !== "string") return key;
96-
logger.warn(`[i18n] Key '${key}' missing in '${loadedLang}', falling back to '${FALLBACK_LANG}'.`);
97+
// Warn once per key — t() runs per rendered string, so an untranslated
98+
// locale would otherwise flood the log on every roundup.
99+
const warnKey = `${loadedLang}:${key}`;
100+
if (!warnedMissingKeys.has(warnKey)) {
101+
warnedMissingKeys.add(warnKey);
102+
logger.warn(`[i18n] Key '${key}' missing in '${loadedLang}', falling back to '${FALLBACK_LANG}'.`);
103+
}
97104
}
98105
return interpolate(value, vars);
99106
}
@@ -102,4 +109,5 @@ export function resetI18nCache() {
102109
translations = null;
103110
englishFallback = null;
104111
loadedLang = null;
112+
warnedMissingKeys.clear();
105113
}

0 commit comments

Comments
 (0)