Release v1.5.5: Weekly Roundup#106
Draft
retardgerman wants to merge 54 commits into
Draft
Conversation
- Add Weekly Roundup config section to dashboard (enable toggle, channel select, weekday, hour, embed color) - Wire WEEKLY_ROUNDUP_CHANNEL_ID into Discord channel loader - Bump package.json to 1.5.4 and add CHANGELOG entry
- Add utils/i18n.js: minimal Node-side loader that reads
locales/<LANGUAGE>.json with fallback to en.json, exports t(key, vars)
with dot-notation lookup and {placeholder} interpolation
- Add roundup.* namespace to en/de/sv/template locales
- Replace all user-visible strings in bot/weeklyRoundup.js with t() calls
(embed title, season/episode labels, footer, fallbacks)
- Logger messages remain in English by convention
Must-fix from code-review + silent-failure-hunter:
- Scheduler: now.getHours() === targetHour (was <, caused re-posts
throughout the target hour+)
- Failure counter scoped per-week with weekKey, resets automatically
on entering a new week (was process-global, permanently died after
3 lifetime fails)
- fetchRecentlyAdded throws on error instead of returning []; roundup
and poller wrap it properly so a Jellyfin outage no longer looks
like a quiet week
- Rename Jellyfin filter param: MinDateLastSaved -> MinDateCreated
(LastSaved changes on metadata refresh, wrong semantics)
- escapeMd now also escapes * _ ~ ` to prevent titles like *Batman*
from breaking bold formatting inside the link label
- sendWeeklyRoundup: channel-fetch failure now logs a warn, no more
silent return
- runTick wrapper catches rejected promises from setTimeout/setInterval
- markPosted: on persistence failure, do NOT set process.env so the
next tick retries (was in-memory-only success)
- resolveLibraryNames: propagates errors to caller instead of silently
returning {} (caller now fails the tick and bumps failure count)
- parseIntInRange for WEEKDAY/HOUR rejects NaN/out-of-range values
- i18n: whitelist LANGUAGE env var against ^[a-zA-Z]{2,3}([_-]...)?$
so a malicious or typoed value cannot point fs.readFileSync at an
arbitrary path
- formatDate: normalize LANGUAGE to primary BCP-47 subtag before
passing to Intl
Also: buildJellyfinUrl: log fallback at error level so malformed
JELLYFIN_BASE_URL is loud in ops logs.
If the Discord post succeeded but writing lastPostedAt to config failed, the previous logic bumped the weekly failure counter. After 3 of those the whole week was skipped despite users having already seen the post. Set the in-memory env var unconditionally so the same process won't re-post, and log the persistence failure without touching the counter.
- resolveLibraryNames: catch fetchLibraries errors and fall back to the generic library label instead of letting a names-only blip nuke the whole weekly post - formatDate: log the Intl failure before falling back to ISO date - buildJellyfinUrl: ensure the concat fallback is a syntactically valid URL (prefix with http://invalid.local/ sentinel when JELLYFIN_BASE_URL lacks a scheme) so downstream ButtonBuilder.setURL surfaces the misconfig clearly rather than with an opaque validation error
The weekly_roundup_* and weekday_* data-i18n keys were present in the HTML but never added to the locale files. Users on non-English locales saw raw keys like 'config.weekly_roundup_title' instead of translated labels. Added all keys to en, de, sv, and template.
Mirrors the existing 'Test Random Pick' pattern — posts the weekly roundup immediately without waiting for the scheduled weekday/hour. sendWeeklyRoundup gains an options.test flag. In test mode it rethrows errors (so the HTTP handler can surface them) and skips all state side effects (lastPostedAt, failure counter) so a test run never masks or replaces the real scheduled post.
Log the raw Jellyfin item count and the post-library-filter count on every fetch so the actual filtering stage is visible. In test mode, distinguish three failure cases when nothing would be posted: - no notification libraries configured at all - Jellyfin returned items but the library filter dropped all of them - Jellyfin returned nothing in the 7-day window The scheduled path is unchanged — a library mismatch is still a normal quiet week for that user, not a failure to count.
Jellyfin libraries have a CollectionId (referenced by Item.ParentId / AncestorIds) and a separate VirtualFolderItemId (stored in JELLYFIN_NOTIFICATION_LIBRARIES via the dashboard). Comparing AncestorIds directly against the config keys never matched, so the roundup filtered every item out even when Jellyfin returned 200. fetchWindowItems now calls fetchLibraryMap() and runs every candidate ID through resolveConfigLibraryId() before checking membership — the same translation the webhook flow already does. The resolved id is stashed on each item as _configLibraryId so groupItems can reuse it without redoing the lookup.
When Jellyfin returns items but every one is filtered out, dump the configured library ids, the known Jellyfin library ids in both forms, and the first item's ParentId/AncestorIds (raw + translated) so we can see exactly where the comparison diverges.
…ering The previous approach fetched the 200 most recent Jellyfin items globally and then filtered by AncestorIds. That breaks when items live in libraries that /Library/VirtualFolders doesn't return (BoxSets, Collections, unmapped folders) — every item gets dropped even though they're really inside a configured library. Now we issue one /Items query per configured library id with ParentId + Recursive, which delegates membership resolution to Jellyfin itself. No more two-form-id dance, no more ancestor walks. Also defensively skip non-hex-32 entries in JELLYFIN_NOTIFICATION_LIBRARIES — observed an 'on' string leaking in from somewhere upstream, will need a separate fix for the source.
Sonarr quality upgrades delete and re-import the same episode file,
which Jellyfin can register as multiple new items with the same
SeriesId + ParentIndexNumber + IndexNumber within the same week. The
previous counter would then report '3 new episodes' for what was
actually the same episode imported three times.
seasons is now Map<seasonNum, Set<episodeKey>>, where episodeKey is
'e{IndexNumber}' for normal episodes and falls back to the Jellyfin
item id for unnumbered specials (which keeps per-item dedup but lets
specials still appear).
Episode dedup key now prefers, in order: IndexNumber + IndexNumberEnd (2-parters), IndexNumber alone, lowercased Name (Sonarr re-imports keep the title), then item id as last resort. The previous version fell straight to item id when IndexNumber was missing, which made every re-import look like a different episode. Also log the raw identity fields for the first 30 episodes per run so we can see exactly what Jellyfin returns and confirm where dedup breaks if a case still slips through.
# Conflicts: # .gitignore # CHANGELOG.md
- Corrupt WEEKLY_ROUNDUP_LAST_POSTED_AT now warns and skips the tick instead of silently falling through and re-posting. - Drop the redundant outer try/catch in runTick — the setInterval wrapper already catches and logs, and the inner try only masked design intent (no path inside throws synchronously today). - Empty-week branch logs at warn (with a diagnostic) when no items match because of misconfig (no notification libraries, or N items all filtered out by library mismatch). A genuinely quiet week still logs at info.
- Preflight JELLYFIN_BASE_URL in sendWeeklyRoundup so a malformed value no longer makes it into the embed as http://invalid.local sentinels. Logs error, bumps the failure counter, aborts the post. - resolveLibraryNames now returns { map, failed }; embed footer notes "library names unavailable" when fallback was hit and there is more than one library section, so Discord viewers see why headers look generic. - Embed field truncation builds entry-by-entry up to the 1024-char budget instead of byte-slicing the joined string. Adds a translated "… and N more" line when entries had to be dropped, so a markdown link is never cut in half. - Hourly tick uses now.getHours() >= targetHour. If the bot was down or the tick drifted past the boundary, the digest catches up later the same day rather than skipping the entire week. The 6-day idempotency guard prevents duplicates on re-tick. - Episode raw-fields diagnostic dump downgraded from info to debug — no longer noise during normal operation. - utils/i18n.js: resolve LOCALES_DIR relative to the module via fileURLToPath so the loader works regardless of cwd. Drop the existsSync/readFileSync race window (single readFileSync, treat ENOENT as "missing locale", log other errors). - /api/test-weekly-roundup error response: coerce error.message to string and slice to 500 chars so a future axios/discord upgrade can't leak unbounded data into the dashboard. Locale keys added: roundup.library_names_unavailable, roundup.field_more in en, de, sv, template.
…-seen map Roundup queried Jellyfin /Items?MinDateCreated, which surfaces re-imported files (quality upgrades) as if brand-new — fresh ItemId AND fresh DateCreated. Now records each item under a stable key (TMDB for movies/series, SeriesId+S/E for episodes/seasons) the first time it's seen, and filters out items whose firstSeenAt is older than the 7-day window.
…ponse - weeklyRoundup: JELLYFIN_BASE_URL preflight now also enforces http(s) scheme, matching the SSRF guard used by the config-test routes - app.js /api/test-weekly-roundup: strip URL-shaped substrings from the error message before returning, so axios-embedded URLs (which can carry api_key query params) never leak into the dashboard response
## Summary
Adds an optional **Weekly Roundup** that posts a weekly digest of new Jellyfin content to a Discord channel. Disabled by default; configurable via the dashboard.
## Changes
- **`bot/weeklyRoundup.js`** (new): hourly scheduler tick, 7-day rolling window fetch from Jellyfin, per-library grouping, series/season/episode collapsing (e.g. _"My Show — Seasons 1 & 2 (12 episodes)"_), embed builder with Jellyfin deeplinks, 3-strike back-off on consecutive failures
- **`bot/botManager.js`**: wires `scheduleWeeklyRoundup(client)` into the `clientReady` handler next to the daily random pick
- **Config** (`lib/config.js`, `utils/validation.js`): adds `WEEKLY_ROUNDUP_{ENABLED,CHANNEL_ID,WEEKDAY,HOUR,EMBED_COLOR,LAST_POSTED_AT}` keys and Joi validators
- **Dashboard** (`web/index.html`, `web/script.js`): new "Weekly Roundup" section with enable toggle, channel select, weekday dropdown, hour input, embed color; `WEEKLY_ROUNDUP_CHANNEL_ID` is populated via the existing Discord channel loader
- **`utils/i18n.js`** (new): minimal server-side i18n loader. Reads `locales/<LANGUAGE>.json` with fallback to `en.json`; exports `t(key, vars)` with dot-notation lookup and `{placeholder}` interpolation
- **Locales** (`locales/{en,de,sv,template}.json`): new `roundup.*` namespace covering embed title, season/episode labels, footer, and fallbacks — all user-visible strings in the roundup go through `t()`
- **Helpers**: `utils/jellyfinUrl.js` extracted from `jellyfinWebhook.js` so the roundup can build Jellyfin deeplinks without duplicating logic; `api/jellyfin.js::fetchRecentlyAdded` gained an optional `minDateCreated` param (maps to Jellyfin's `MinDateLastSaved`) to support the rolling 7-day window
## Idempotency
The scheduler ticks every hour and gates on a persisted `WEEKLY_ROUNDUP_LAST_POSTED_AT` timestamp, so:
- Docker restarts don't cause duplicate posts within the same week
- The hour/weekday gate is checked on every tick — a missed window (e.g. container down at the exact hour) posts on the next matching hour
- `ALREADY_POSTED_MIN_AGE_MS` is 6 days (not 7) to tolerate small scheduler drift
## Version
Bumps `package.json` to `1.5.4` and adds a `CHANGELOG.md` entry.
> AI-assisted documentation. Code logic manually verified.
Jellyfin's /Items endpoint silently ignores MinDateCreated, so passing it returned the most recent N items regardless of the requested cutoff. Switch to the supported MinDateLastSaved param and add StartIndex-based pagination with an early-break once results fall below the cutoff.
Three fixes that together stop bloated digests: - installedAt floor: persists the bot's first-start timestamp to config/dedup-roundup-state.json. Items with DateCreated older than installedAt are dropped — the first roundup after install/upgrade no longer pulls in the back-catalogue. - Client-side DateCreated cutoff: MinDateLastSaved is a superset of recently added (advances on metadata refresh too), so enforce the 7-day window in code before grouping. - TZ-aware scheduler: now.getHours() returned UTC inside default Docker images. New optional WEEKLY_ROUNDUP_TZ env var pins weekday/hour to a specific timezone via Intl.DateTimeFormat; absent, falls back to host time (which already respects the TZ env var).
…s, silent caps - fetchRecentlyAdded: per-page try/catch so a transient error on page N returns the items already collected from pages <N instead of throwing the whole call away. Tag every break path with a stopReason for diagnosis. - maxTotal cap is no longer silent: warn when hit so an operator knows older items in the window were truncated. - weeklyRoundup filter: items with missing/unparseable DateCreated are now dropped explicitly (with their own counter) instead of slipping past both the installedAt floor and the 7-day cutoff. Filter summary always logs at debug level. - WEEKLY_ROUNDUP_TZ validation runs once at scheduler start with a loud warn; per-tick fallback drops to debug to avoid hourly log spam. - roundupState: log on every installedAt stamp (first-ever or restamp after PersistentMap rejected a corrupt value).
…able evaluateTick
…row on send failure
…l backslash-paren)
… when seasons exist
…ntion under 1.5.5" This reverts commit b9c7c81.
…-safe legacy migration
Replace the Role-ID text input with a <select> populated from /api/discord-roles
when the bot is running. Keeps the same WEEKLY_ROUNDUP_ROLE_ID config field
(snowflake string), so server-side validation and the role-mention send path
are unchanged. Adds the missing config.weekly_roundup_role_{label,help,none}
keys to en/de/sv/template.
Roundup scheduler rebuild + role mention + render fixes
Each library now gets a separator header field, then separate fields for Movies and Series/Episodes. Long sections spill into continuation fields instead of being silently truncated. Removes the old field_more key in favour of field_continued.
…ettings Replaces the single EMBED_SHOW_OVERVIEW flag with two independent options: EMBED_SHOW_OVERVIEW_MOVIES (movies + series) and EMBED_SHOW_OVERVIEW_EPISODES. Allows users to disable episode summaries to avoid spoilers without affecting movie/series notifications. Both default to true. Dashboard updated with two separate checkboxes; i18n keys added for en/de/sv.
Versions 1.0.0–1.15.1 had multiple high-severity issues including prototype pollution gadgets, SSRF bypass, CRLF injection, and credential injection. 1.16.0 addresses all reported advisories.
writeConfig now writes to a .tmp file and renames atomically, so a process kill during a write leaves the original config.json intact instead of corrupted/empty. updateConfig now refuses to write if readConfig() returns null, preventing a partial one-key file from replacing a fully configured config when secrets are auto-generated after a corrupted read.
…ge audit - api/jellyfin.js: log warn instead of silently swallowing errors in findLibraryByAncestors recursive search - utils/configFile.js: distinguish corrupt/unreadable config from "not found" in error message - bot/weeklyRoundup.js: throw on null getLibraryChannels(); show library-names-unavailable footer for single-library setups too; strip newlines from escapeMd; renderMovie/Series/Season now gracefully degrade to plain bold when itemDeeplink returns empty; remove dead isTest branch from onError - bot/roundupState.js: use type-safe check in migration guard instead of != null
- fix(roundup): split embed fields by type (Movies / Series) per library - feat(embeds): split overview toggle into separate movie and episode setting - fix(deps): upgrade axios to 1.16.0 (resolves high-severity CVEs) - fix(config): prevent config wipe on mid-write crash or unreadable config
…appear in roundup Previously sentNotifications expired after 7 days and roundup-first-seen after 14 days. A Sonarr/Radarr quality upgrade landing after those windows was treated as a brand-new item. Both windows are now ~5 years, which is effectively permanent for any realistic install lifetime. Also fixes the [DUPLICATE CHECK] log line that was checking debouncedSenders with raw SeriesId instead of the stable seriesKey, causing it to always report `has debouncer: false` for keyed series.
…appear in roundup
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Brings v1.5.5 from `dev` to `main`. The headline feature is Weekly Roundup — an optional, off-by-default scheduled Discord digest of new Jellyfin content from the last 7 days.
Weekly Roundup
lastPostedAttimestamp + hourly scheduler tick guarantee one post per week even if the container restarts on the trigger day.Hardening
JELLYFIN_BASE_URLpreflight enforceshttp(s)scheme on the weekly roundup code path, matching the SSRF guard used by config-test routes./api/test-weekly-roundupstrips URL-shaped substrings fromerror.messagebefore responding, so axios-embedded URLs (which can carryapi_keyquery params) never leak into the dashboard response.Other
Beta has been live on `dev` (`nairdah/anchorr:dev`) — see discussion #105.