Skip to content

feat: weekly roundup (v1.5.4)#103

Draft
retardgerman wants to merge 20 commits intodevfrom
feature/weekly-roundup
Draft

feat: weekly roundup (v1.5.4)#103
retardgerman wants to merge 20 commits intodevfrom
feature/weekly-roundup

Conversation

@retardgerman
Copy link
Copy Markdown
Contributor

@retardgerman retardgerman commented Apr 24, 2026

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.

- 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.
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