Skip to content

Release v1.5.5: Weekly Roundup#106

Draft
retardgerman wants to merge 54 commits into
mainfrom
dev
Draft

Release v1.5.5: Weekly Roundup#106
retardgerman wants to merge 54 commits into
mainfrom
dev

Conversation

@retardgerman
Copy link
Copy Markdown
Contributor

@retardgerman retardgerman commented Apr 29, 2026

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

  • Scheduled Discord post summarizing new Jellyfin content from the past 7 days. Configurable channel, weekday, hour, and embed color via the dashboard. Items are grouped by library; episodes of the same series collapse into one line (e.g. "My Show — Seasons 1 & 2 (12 episodes)"). Titles link to Jellyfin.
  • Idempotent across restarts: persisted lastPostedAt timestamp + hourly scheduler tick guarantee one post per week even if the container restarts on the trigger day.
  • Sonarr/Radarr upgrades filtered via a stable-identity first-seen map — re-imported files don't show up as new content.

Hardening

  • JELLYFIN_BASE_URL preflight enforces http(s) scheme on the weekly roundup code path, matching the SSRF guard used by config-test routes.
  • /api/test-weekly-roundup strips URL-shaped substrings from error.message before responding, so axios-embedded URLs (which can carry api_key query params) never leak into the dashboard response.

Other

  • Atomic config write prevents a wipe if the process is killed mid-write or the config file is unreadable on startup.
  • axios bumped to 1.16.0 to resolve high-severity CVEs.
  • Embed overview toggle split into separate settings for movies/series and episodes.

Beta has been live on `dev` (`nairdah/anchorr:dev`) — see discussion #105.

AI-assisted documentation. Code logic manually verified.

- 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.
- 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).
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.
@retardgerman retardgerman marked this pull request as draft May 4, 2026 10:44
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant