Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.

---

Expand Down
5 changes: 4 additions & 1 deletion app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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})`)
);
Expand Down Expand Up @@ -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");
Expand All @@ -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");
Expand Down
84 changes: 33 additions & 51 deletions bot/weeklyRoundup.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}

/**
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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 = {}) {
Expand Down Expand Up @@ -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) {
Expand All @@ -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
Expand All @@ -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.";
Expand All @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down
7 changes: 4 additions & 3 deletions jellyfin/libraryPruner.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
);

Expand Down
6 changes: 3 additions & 3 deletions jellyfin/librarySeeder.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`
Expand Down
2 changes: 2 additions & 0 deletions locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions locales/sv.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 9 additions & 1 deletion utils/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
Expand All @@ -102,4 +109,5 @@ export function resetI18nCache() {
translations = null;
englishFallback = null;
loadedLang = null;
warnedMissingKeys.clear();
}
Loading