Skip to content

Comments

feat: Spoiler Mode — per-user spoiler protection#399

Draft
4eh5xitv6787h645ebv wants to merge 65 commits inton00bcodr:mainfrom
4eh5xitv6787h645ebv:spoil
Draft

feat: Spoiler Mode — per-user spoiler protection#399
4eh5xitv6787h645ebv wants to merge 65 commits inton00bcodr:mainfrom
4eh5xitv6787h645ebv:spoil

Conversation

@4eh5xitv6787h645ebv
Copy link
Contributor

@4eh5xitv6787h645ebv 4eh5xitv6787h645ebv commented Feb 17, 2026

What is Spoiler Mode?

Spoiler Mode is a per-user, per-show/movie spoiler protection system that prevents you from accidentally seeing episode titles, thumbnails, and plot details for content you haven't watched yet.

If you're binge-watching a series and browse your library, Next Up sections, search results, or the player overlay — Spoiler Mode makes sure you don't see anything beyond where you've watched.

The Problem

Jellyfin shows episode titles, thumbnails, and descriptions everywhere — home page, detail pages, search results, and even the player overlay. For shows where episode titles alone are spoilers (think Game of Thrones, Breaking Bad, etc.), there's no way to avoid them. The same applies to movie collections (e.g. Lord of the Rings, Star Wars) — browsing the collection page reveals posters and descriptions for movies you haven't seen yet.

The Solution

Spoiler Mode introduces a "spoiler boundary" — the last episode you've fully watched. Everything beyond that boundary gets automatically redacted:

  • Episode titles are replaced with "S02E05 — Click to reveal"
  • Thumbnails are blurred (or replaced with generic tiles in Strict mode)
  • Overviews/descriptions are hidden with a "tap to reveal" placeholder
  • Chapter names in the player OSD are redacted
  • Future season card artwork is blurred
  • Unwatched movie posters are blurred with a SPOILER badge (standalone movies & collection members)
  • Collection detail pages blur unwatched movies while showing watched ones normally

Screenshots

User Settings Panel

The full Spoiler Mode settings live inside the Jellyfin Enhanced settings panel (Settings → Advanced Settings → Spoiler Mode). Controls include master enable, spoiler buttons toggle, disable confirmation, preset selection (Balanced/Strict), and per-surface protection toggles:

Settings panel showing Spoiler Mode controls

Admin Server Toggle

Admins can enable or disable Spoiler Mode server-wide from the Jellyfin Enhanced plugin config page. When disabled, all spoiler UI, redaction, and settings are removed. User data is preserved and restored when re-enabled:

Admin config page with Spoiler Mode enable checkbox

Detail Page Toggle

Navigate to any Series, Movie, or Collection detail page to toggle spoiler protection. The shield icon turns orange when active:

Spoiler ON Spoiler OFF
Toggle ON — orange shield Toggle OFF — outlined shield

Confirmation Dialog

When clicking an active spoiler toggle, a confirmation dialog offers three options — Reveal Temporarily (countdown timer), Disable Protection (turn off), or Cancel:

Confirmation dialog with Reveal Temporarily, Disable Protection, and Cancel

Home Page Protection

Spoiler-protected content on the home page (Continue Watching, Next Up, Recently Added) is automatically blurred with a SPOILER badge. This applies to both series episodes and standalone movies. Click any blurred card to temporarily reveal it:

Home page with blurred spoiler cards — Arcane (series) and Body Double (movie) show SPOILER badges in Continue Watching

Episode & Season Protection

On season detail pages, the system knows your watch boundary. Watched episodes display normally while unwatched episodes are redacted:

Season page showing watched episodes above and blurred episodes below the boundary

On the series page, future seasons beyond your watch progress are blurred:

Series page with Season 2 card blurred while Season 1 is visible

Reveal All Timer

The Reveal All button temporarily shows all spoilers with an orange countdown banner. When the timer expires, everything re-redacts automatically:

Orange reveal banner showing countdown timer with Hide Now button

Movie & Collection Support

Standalone movies — enable spoiler mode on a movie detail page to blur the poster everywhere when unwatched, auto-reveals once marked as watched:

Movie detail page with spoiler protection active

Movie collections (BoxSets) — enable on a collection page to blur unwatched members while showing watched ones normally:

Collection page showing one watched movie and three blurred unwatched movies

Search Results

Search results for protected content are also redacted:

Search results page showing Altered Carbon results


Feature List

Protection Surfaces

  • Home page (Next Up, Continue Watching, Recently Added)
  • Series/Season detail pages (episode lists, season cards)
  • Movie detail pages (overview, backdrop)
  • Collection (BoxSet) detail pages (unwatched movie cards)
  • Search results (episode cards + movie cards)
  • Player OSD (episode title, chapter names, chapter thumbnails)
  • Calendar events

Reveal Controls

  • Click-to-reveal on desktop (click the redacted title text)
  • Long-press on mobile (press and hold 300ms)
  • Reveal All button with configurable countdown and auto re-hide
  • Mouseleave auto-hides revealed cards on desktop

Presets

  • Balanced — blurs artwork, hides episode titles/descriptions
  • Strict — generic tiles (heavy blur + desaturated), hides runtime, air dates, and guest stars

Settings

  • Master on/off toggle
  • Per-surface protection toggles (Home, Search, Player Overlay, Calendar, Recently Added)
  • Artwork policy (Blur / Generic Tiles)
  • Hide Runtime, Air Date, Guest Stars toggles
  • Show/hide spoiler buttons on detail pages
  • Disable confirmation dialog toggle
  • Reveal duration selector (5s / 10s / 30s / 60s)
  • Watched threshold (Fully Played / 90%+ / 50%+)

Auto-Enable

  • First play detection — automatically enables when you start S01E01 of a new series
  • Tag matching — auto-enables for series with specific Jellyfin tags

Admin Controls

  • Server-wide enable/disable toggle on the plugin config page
  • User data preserved when server-wide disabled, restored when re-enabled

Technical Details

  • Per-user storage via spoiler-mode.json (no server-side schema changes)
  • LRU cache with eviction for boundary, movie watched state, and collection membership lookups
  • API request throttling with semaphore (max 4 concurrent boundary requests)
  • Request deduplication for in-flight API calls
  • Pre-hide CSS prevents spoiler flash before async processing completes
  • Single unified MutationObserver with surface-aware gating
  • GUID validation on all API URL construction
  • XSS-safe DOM methods throughout (no innerHTML)
  • Reverse collection member map for O(1) movieId → collectionId lookups

Files Changed

File Change Lines
js/enhanced/spoiler-mode.js New — core module ~3300
js/enhanced/ui.js Settings panel integration +214
js/enhanced/pausescreen.js Player overlay hooks +57 / -23
js/plugin.js Module loader +17
js/enhanced/icons.js Shield icons +12
js/arr/calendar-page.js Calendar hooks +7
Configuration/configPage.html Admin toggle UI +18
Configuration/PluginConfiguration.cs Server config property +6
Configuration/UserConfiguration.cs Per-user storage model +43
Controllers/JellyfinEnhancedController.cs API endpoint +37

Implementation Notes

This PR was developed with AI assistance (Claude). All changes have been reviewed, tested, and understood. The implementation includes:

  • E2E Playwright tests for settings panel, detail page, spoiler toggles, and XSS validation
  • Security review (no CRITICAL/HIGH findings)
  • Code quality review (all actionable items addressed)
  • JSDoc documentation on all functions per CONTRIBUTING.md guidelines
  • Full compliance with existing IIFE code patterns

Testing

  • Feature works as expected
  • No console errors (only console.warn in error handlers)
  • Compatible with Jellyfin 10.11.x
  • Tested on Chromium
  • Works on different browsers (Firefox, Chromium)
  • Doesn't break existing functionality
  • Mobile compatibility — needs some work with icons

4eh5xitv6787h645ebv and others added 30 commits February 16, 2026 20:21
…Slice 1)

Introduces the Spoiler Mode feature for Jellyfin Enhanced. This slice
adds the core data model, per-user persistence via spoiler-mode.json,
detail page toggle button (shield icon), reveal controls (tap-to-reveal,
30s reveal-all), boundary computation for TV series, card redaction
engine, and MutationObserver-based surface protection.

Files added:
- js/enhanced/spoiler-mode.js — full module (IIFE pattern, no innerHTML)
- docs/spoiler-mode/01-discovery.md — architecture mapping
- docs/spoiler-mode/02-design.md — data model, presets, redaction rules

Files modified:
- js/plugin.js — fetch spoiler-mode.json, load script, initialize module
- js/locales/en.json — 10 translation keys for spoiler mode UI
- Specials (season 0) now checked individually by Played status instead
  of boundary comparison, preventing false-negative spoiler leaks
- Boundary computation finds furthest watched episode by max
  season/episode number rather than last in sort order
- Fixed falsy-zero bug: season 0 was treated as missing data
- isEpisodePastBoundary returns null for specials, signaling callers
  to use individual UserData checks
- Removed invalid IsSpecialSeason API parameter
- Calendar events for protected series now have episode titles redacted
  via JE.spoilerMode.filterCalendarEvents() integration in all 3 render
  modes (month, week, agenda)
- Surface protection extended: 'upcoming' explicitly listed,
  'recentlyadded'/'upcoming' respect protectRecentlyAdded setting
- Home sections (Next Up, Continue Watching, Recently Added) already
  covered by MutationObserver card filtering from Slice 1
- Search page navigation now triggers staggered re-scans to catch
  asynchronously loaded episode results
- Home page navigation triggers card re-scans for Next Up / Continue
  Watching / Recently Added sections
- Both use the existing boundary + card filtering engine from Slice 2
- Player item ID detection now uses OSD favorite button (most reliable)
  with URL hash fallback, matching osd-rating.js pattern
- Player page navigation hook consolidated to single staggered callback
- OSD MutationObserver uses same improved item ID detection
- Boundary cache TTL (5min) handles stale data after playback
- handleAutoEnableOnFirstPlay now also checks tag-based auto-enable
  rules when playback starts, fetching series tags via API
- Skips all checks efficiently when neither autoEnableOnFirstPlay nor
  tagAutoEnable are configured
- Tag-based auto-enable on detail pages already functional from Slice 1
- Fetches Tags field alongside episode data for combined checks
…s (Slice 7)

Add Spoiler Mode settings section to the JE settings panel with:
- Preset selector (Balanced/Strict) controlling redaction intensity
- Per-surface toggles (Home, Search, Overlay, Calendar)
- Auto-enable on first play toggle
- Protected items count display
- 16 new translation keys for settings UI
Comprehensive manual testing runbook covering all 14 test scenarios:
- Per-item toggle, boundary computation, specials handling
- Settings panel presets and per-surface toggles
- All 5 surfaces (episode list, home, search, overlay, calendar)
- Reveal controls, auto-enable, tag-based enable
- Persistence, edge cases, API and console verification
- Add GET/POST routes for spoiler-mode.json in C# controller (was 404)
- Add UserSpoilerMode model class with rules, settings, tag auto-enable
- Add SHIELD icon to icon system (IconName, emoji, Lucide SVG, MUI)
- Fix infinite MutationObserver loop on detail pages by removing
  attribute watching and adding re-entrancy guard with item ID tracking
- Reduce redundant episode data fetches on detail page navigation
- Switch from CSS filter to backdrop-filter via ::after pseudo-elements
  so play buttons, quality tags, and other overlays stay unblurred
- Increase blur radius from 15px to 30px for heavy progressive-load effect
- Add SPOILER badge to list item images (.listItemImage fallback)
- Hide secondary card text via CSS to prevent episode title leaks
- Redact all card text elements, not just the first title
- Add z-index layering for interactive elements above blur overlay
- Use child combinator (.cardScalable > .cardImageContainer) to apply
  blur ::after only to the main image, not the overlay click target
- Remove position/z-index changes from .cardOverlayContainer and
  .cardOverlayButton which broke their absolute positioning
- Only set z-index on elements inside the blurred image container
  (playedIndicator, listItemImageButton, itemProgressBar)
- Replace backdrop-filter with direct filter:blur() on image elements
  to fix gray card appearance (shows actual blurred content instead)
- Add pre-hide CSS to prevent spoiler flash on page load, scoped to
  episode cards and listItems with data-id to avoid hiding preferences
  and slide-out panel items
- Implement click-to-reveal with mouseleave-to-hide (desktop) and
  long-press/touchend (mobile) replacing the old timed auto-hide
- Guard redactCard against re-redaction from MutationObserver re-scans
- Show series name on home page cards (skip cardText-first when
  secondary text exists)
- Blur future season card posters on series detail page
- Add "Click to reveal" hint to redacted episode titles
- Add E2E test suite with Playwright covering all spoiler mode flows
- Remove undefined handleTapReveal from public API, export revealCard
  and hideCard instead (CRITICAL: was causing ReferenceError)
- Add escapeHtml() helper and sanitize server-derived seriesName before
  passing to JE.toast() to prevent XSS via innerHTML (HIGH)
- Fix countdownInterval leak in activateRevealAll — move to module-level
  variable so deactivateRevealAll can clear it (MEDIUM)
- Add hasOwnProperty guard on PRESETS lookup to prevent prototype
  pollution via crafted preset values (MEDIUM)
Address all 17 unfixed issues from security and code quality review:

HIGH:
- Add LRU eviction (max 50 entries) to boundaryCache and parentSeriesCache
- Extract processSpecialEpisode, processEpisodeWithoutNumbers, processSeasonCard
  from processCard to reduce nesting from 6+ levels to flat early-returns
- Fix direct mutation in ui.js autoEnableOnFirstPlay handler — add immutable
  setAutoEnableOnFirstPlay() setter to public API
- Replace all var declarations with const/let, eliminating userId/userId2/userId3 hack
- Merge 3 MutationObservers into single unified observer with connect/disconnect
  lifecycle tied to protectedIdSet.size

MEDIUM:
- Move lastDetailPageItemId/detailPageProcessing declarations to module scope
- Replace 152-line CSS string array with template literal
- Extract findButtonContainer() helper to deduplicate selector lookup
- Replace magic number timeouts with named constants
- Use Promise.all() for parallel episode card processing in redactEpisodeList

LOW:
- Add GUID validation (isValidId) before all API URL constructions
- Remove item IDs and exception objects from console.warn/error messages
- Add semaphore pattern (max 4 concurrent) for boundary API requests
- Remove console.log from production init path
- Remove never-consumed episodes array from boundaryCache
- Standardize function expression styles throughout

Also updates E2E test to verify future season blur behavior.
Fix 5 actionable items from security and code quality reviews:
- Add isValidId() check in handleDetailPageMutation to validate URL hash IDs
- Add evictIfNeeded() on error path in getParentSeriesId to prevent unbounded cache growth
- Replace silent no-op fallback for debouncedOsdHandler with actual handler function
- Add surface check before detail/OSD handlers in handleMutations to avoid unnecessary work
- Extract magic number 200 to OSD_MUTATION_DEBOUNCE_MS constant
- Add proper JSDoc to handleOsdMutation function
- Add inline comment explaining manual loop optimization in handleMutations
- Add screenshots documenting Spoiler Mode feature for PR
Extend spoiler mode beyond TV series to support standalone movies and
movie collections. Unwatched movies are blurred with a SPOILER badge
on home, search, collection detail, and movie detail pages. Watched
movies auto-reveal. BoxSet detail pages get a spoiler toggle and blur
unwatched member movies.

New functions: isMovieWatched, fetchCollectionItems,
getProtectedCollectionForMovie, blurMovieCard, redactCollectionPage,
redactMovieDetailPage. All use the same caching/dedup patterns as
existing boundary code.
…r leak, metadata reveal

- processCard: move SCANNED_ATTR into try block; on catch, clear
  PROCESSED_ATTR so filterNewCards retries failed cards instead of
  silently skipping them

- pausescreen: check spoiler boundary before displaying episode
  overview — redacts plot text for episodes past the boundary in
  protected series (fixes spoiler leak via autoplay/pause screen)

- redactCard: use CSS visibility for non-title metadata elements
  (runtime, star rating, end time) instead of replacing textContent,
  which destroyed child DOM nodes. Reveal now preserves star icons
  and spacing correctly.
Early returns (non-protected cards, movie cards, series cards) were
skipping SCANNED_ATTR since it was moved into the try block. This left
all non-spoiler episode cards permanently blurred by the pre-hide CSS.

Restore the finally block but use a failed flag to skip marking on
API errors so those cards can still be retried.
…leanup

- Replace 3 loose equality (==) null checks with explicit === null || === undefined
- Prune stale collectionMemberMap entries when rules change or rebuildSets runs
- Store navigation setTimeout IDs and clear them on re-navigation to prevent
  stale callbacks firing after rapid page changes
…duplicate overview hide

- Add tFallback(key, fallback) helper and replace all 14 inline
  JE.t(key) !== key ? JE.t(key) : fallback patterns
- Merge identical blurSeasonCard and blurMovieCard into blurCardArtwork
- Extract hideOverviewWithReveal for shared collection/movie detail pages
  (series detail page keeps its more complex re-entrant version)
- Add enabled === false guard to all entry points: processCurrentPage,
  filterAllCards, handleMutations, handleDetailPageMutation,
  redactSearchResults, redactPlayerOverlay, filterCalendarEvents,
  addSpoilerToggleButton
- Listen for je-spoiler-mode-changed event to trigger processCurrentPage
  when settings change, immediately clearing redactions on disable
- Add catch-all cleanup in clearAllRedactions: remove blur/generic
  classes, badges, and toggle buttons even if REDACTED_ATTR is missing
The previous approach of guarding individual entry points missed ~20
inline isProtected() calls throughout the codebase. By making
isProtected() itself return false when enabled === false, every code
path that checks protection status respects the user toggle.
unredactCard only searched for .je-spoiler-text-redacted elements, but
revealCard removes that class while leaving data-je-spoiler-original on
the element. Cards that had been temporarily revealed (hover/touch)
would not have their titles restored on disable.

- Search by both class and data attribute in unredactCard
- Add catch-all in clearAllRedactions for any [data-je-spoiler-original]
  elements that escaped per-card cleanup
- Clean up reveal bindings on full clear
The GET endpoint deserializes through UserSpoilerMode/SpoilerModeSettings
typed models, which silently dropped any properties not defined in C#.
This caused enabled, showButtons, showDisableConfirmation, autoEnableTags,
autoEnableOnFirstPlay, and defaultPreset to be lost on page reload.

Add all missing properties to SpoilerModeSettings so they survive the
deserialization round-trip.
body.je-spoiler-active CSS applies blur to all episode cards before JS
processes them (prevents spoiler flash). But this was added based only
on protectedIdSet.size, ignoring the enabled setting. On initial load
with enabled=false, cards would flash blurred until JS caught up.

- Check enabled !== false before adding je-spoiler-active to body
- Remove je-spoiler-active from body on settings change event
- Apply fix to both rebuildSets() and initializeSpoilerMode()
- Escape tag values in HTML template to prevent attribute breakout XSS
- Validate tag input: strip HTML-unsafe chars, limit 50 chars/tag, max 20 tags
- Remove unused addRevealAllButton function (52 lines of dead code)
- Guard hideOverviewWithReveal against duplicate click handler binding
Replace 6 old screenshots with 12 new ones covering all Spoiler Mode
surfaces: home page, settings panel, admin toggle, detail page toggle
on/off, confirmation dialog, episode boundary, season blurring, reveal
banner, movie detail, collection page, and search results.

Add scripts/capture-pr-screenshots.mjs (Playwright) for reproducible
screenshot generation.
Reframe to clearly show SPOILER badges and blur on Body Double
(movie) and Arcane (series) cards in Continue Watching section.
Add protectEpisodeDetails setting that redacts overview, backdrop, poster,
metadata, and optionally guest stars on unwatched episode detail pages.
Replace the balanced/strict preset system with flat SETTING_DEFAULTS and
individual checkboxes for all settings.

- Add redactEpisodeDetailPage() with CSS-based poster/metadata hiding
- Add je-spoiler-episode-protected CSS class for episode detail pages
- Add ProtectEpisodeDetails to C# SpoilerModeSettings model
- Add UI checkbox and en.json translation keys
- Remove PRESETS, preset dropdown, and stale C# Preset/DefaultPreset props
- Remove orphaned preset locale keys
- Update activateRevealAll, clearAllRedactions, and navigation cleanup
- Fix stale "strict mode" comments throughout
…fix review issues

- Split 3389-line spoiler-mode.js into 4 focused modules:
  spoiler-mode.js (core), spoiler-mode-redaction.js,
  spoiler-mode-surfaces.js, spoiler-mode-observer.js
- Migrate all API calls from deprecated /Users/{userId}/Items/{id}
  to current /Items/{id} endpoint format for Jellyfin 10.11+
- Fix revealDuration heuristic: replace fragile <= 300 threshold
  with explicit LEGACY_SECOND_VALUES set (5, 10, 15, 30, 60)
- Add LRU eviction to collectionMemberMap (was unbounded)
- Optimize clearAllRedactions: single compound DOM query replaces
  15 separate querySelectorAll calls
- Add ARIA attributes and keyboard focus trap to confirmation dialog
  (role=dialog, aria-modal, Tab/Shift+Tab cycling, focus restore)
- Remove dead data-attribute fast paths (data-seriesid,
  data-parentindexnumber, data-indexnumber) that jellyfin-web
  never sets on cards — always use API lookup
- Remove unused processSpecialEpisode function
- Fix processCard season card flow: use else-if to prevent
  unnecessary episode path execution
- Clean up unused userId variable declarations
- Remove user IDs from C# log messages
- Update e2e tests for preset removal and protectEpisodeDetails
Test scripts are maintained separately and not part of the plugin distribution.
… bugs

Episode detail pages now redact the episode title with "S02E05 — Click
to reveal" format. Movie detail pages fix three issues: remove redundant
isProtected check that blocked collection-protected movies, always hide
overview when protected+unwatched instead of gating on showSeriesOverview,
and blur both poster and backdrop on blur/generic artwork policy.
The spoiler-mode modules (core, redaction, surfaces, observer) require
sequential loading but were loaded in parallel via Promise.allSettled,
causing intermittent failures when dependent modules loaded before core.

Split loadScripts into parallel (independent scripts) and sequential
(spoiler-mode chain) loading to guarantee execution order.

Also fix detail page redaction timing: Jellyfin renders title, overview,
and backdrop asynchronously after the initial DOM mutation. Apply
redaction immediately and re-apply after 800ms and 2s to catch
late-rendered elements. Fix backdrop selector to use document.querySelector
(backdrop lives outside #itemDetailPage) and poster selector to target
.cardImageContainer (Jellyfin uses background-image, not <img>).
…pter redaction

Three fixes:

1. Episode poster CSS targeted .detailImageContainer img but Jellyfin
   uses .cardImageContainer with background-image. Add .cardImageContainer
   to the CSS blur rule.

2. Episode title "Click to reveal" had no click handler. Add click-to-reveal
   with auto-hide after revealDuration, matching the overview behavior.

3. Chapter cards have class "card" and data-id, causing the general card
   scanner to treat them as regular movie cards and redact ALL of them,
   bypassing the watched/unwatched distinction. Exclude .chapterCard from
   CARD_SEL and CARD_SEL_NEW so chapters are only handled by
   redactDetailPageChapters which respects playback position.

Also add delayed re-application for chapter redaction (same timing fix
as overview/poster/backdrop) and update reveal-all cleanup to clear
inline blur on .cardImageContainer elements.
…osters

Episode poster (CSS-class blur) and movie poster (inline-style blur)
now support click-to-reveal with auto-hide after revealDuration.

Add bindPosterReveal helper that handles both blur mechanisms: toggling
je-spoiler-poster-revealed CSS class for CSS-based blur, or swapping
inline filter style for inline-based blur. Add corresponding CSS override
rule so the revealed class beats the !important blur rule.
…verlay

Episode chapters were incorrectly redacting watched chapters due to
redundant API fetches failing under load. Pass the already-known
PlaybackPositionTicks from the episode item to redactDetailPageChapters
instead of re-fetching. Accept optional knownPlaybackTicks parameter.

Add visible "Click to reveal" text overlay on blurred episode and movie
posters. The overlay hides during reveal and reappears when blur returns.
The observer unconditionally called redactDetailPageChapters for ALL
detail pages regardless of protection status. Each type-specific handler
(movie, episode) already calls it internally when the item is protected,
so the generic call was redundant and incorrectly redacted chapters on
unprotected items.
Poster overlay was a child of the blurred element so it got blurred too
(gray box). Move overlay to be a sibling inside .detailImageContainer
(which is not blurred) so text is readable on top of the blurred poster.

For chapters, revert knownPlaybackTicks approach — use ApiClient.getItem
with proper user context for reliable PlaybackPositionTicks. Add
per-page dedup guard (jeSpoilerChaptersProcessed) to prevent concurrent
calls from racing. Delayed re-scans reset the guard to catch late-
rendered chapters.
…ched chapters

Poster overlay was placed in .detailImageContainer which has wrong
positioning context. Move to .cardBox which is the card's positioned
container and a sibling parent of the blurred .cardImageContainer.

For chapters, add safety net: after fetching playback position, any
watched chapter that was incorrectly blurred by a racing code path
gets forcibly unredacted via core.unredactCard. This runs on every
call including delayed re-applications, ensuring watched chapters
are always visible regardless of processing order.
…leak fix

Comprehensive refactor based on code review findings:

- Add JSDoc with @param/@returns to all functions in core and redaction
  modules per CONTRIBUTING.md requirements
- Extract getCardItemId and blurElement helpers to core module, removing
  duplicate definitions in surfaces.js and observer.js
- Define LATE_RENDER_FIRST_DELAY_MS (800) and LATE_RENDER_FINAL_DELAY_MS
  (2000) constants, replacing hardcoded magic numbers
- Fix duplicate cardBox lookup in redactCard (queried twice, now once)
- Fix event listener leak: clearAllRedactions no longer removes
  jeSpoilerRevealBound/jeSpoilerOverviewBound data attributes since
  the associated event listeners persist (prevents double-binding)
- Remove dead jeSpoilerChaptersProcessed reference
- Replace 5 inline blur patterns with core.blurElement() calls
processEpisodeWithoutNumbers, processSeasonCard, and processCard called
core.redactCard, core.blurCardArtwork, and core.bindCardReveal without
checking if they were registered. When the redaction module loads late
(race condition), these throw "is not a function" errors. Add defensive
null guards to all 7 call sites.
…on fetch

redactDetailPageChapters used ApiClient.getItem() which fetches the full
item payload. Only UserData.PlaybackPositionTicks is needed. Switch to
ApiClient.ajax with Fields=UserData for a smaller, faster response.
Matches the pattern used in redactPlayerOverlay.
…d scanner

Three-layer fix to stop watched episode chapters from being blurred:

1. Add early return in processCard() for .chapterCard elements — belt
   and suspenders with the CARD_SEL_NEW CSS exclusion, since processCard
   can be called from other code paths besides filterNewCards.

2. Exclude .chapterCard from hardcoded selectors in redactEpisodeList()
   and redactCollectionPage() which used .card[data-id] without the
   exclusion.

3. Switch chapter playback position API from /Items/{id}?Fields=UserData
   to /Users/{userId}/Items/{id} which always returns user-specific
   PlaybackPositionTicks reliably.
ROOT CAUSE: Chapter cards have data-type="Episode" and no
data-je-spoiler-scanned attribute. The CSS pre-hide rule
.card[data-type="Episode"]:not([scanned]) was blurring ALL chapter
cards via pure CSS, overriding the JS-level chapter redaction that
correctly skips watched chapters based on playback position.

Add :not(.chapterCard) to all pre-hide CSS selectors so chapter cards
are only blurred by the JS-level redactDetailPageChapters function
which respects PlaybackPositionTicks.
…n blurCardArtwork

1. pausescreen.js: ParentIndexNumber can be undefined for specials,
   causing isEpisodePastBoundary to silently return false (no redaction).
   Add || 0 fallback so specials correctly trigger null → redact path.

2. blurCardArtwork: badge container lookup only checked .cardImageContainer
   and .cardImage but not .listItemImage, so list-view blurred items
   didn't get the SPOILER badge. Add .listItemImage to the selector
   chain (matching redactCard which already includes it).
Series/season detail pages were making 2-3 API calls per episode card
(getParentSeriesId + item fetch + boundary check), causing 40+ requests
for a 20-episode page and gradual one-by-one unblurring.

computeBoundary() already fetches all episodes for a series in one call.
Now it also caches each episode's data (Id, season/episode numbers,
UserData) in episodeDataCache. redactEpisodeList() uses this cached data
to redact/skip cards synchronously — no per-card API calls needed.

Cards that aren't found in cache (non-episode cards, edge cases) fall
back to the original processCard() path.

Result: series detail pages go from 40+ API calls to 1, and episode
cards appear in their final state immediately instead of gradually
unblurring.
On series/season detail pages where the series is NOT protected, the
observer now immediately marks all cards as processed+scanned, preventing
filterNewCards from individually calling getParentSeriesId on each card.

Also pre-marks cards as PROCESSED before the async API call in
handleDetailPageMutation to prevent a race where filterNewCards fires
(50ms debounce) before the handler knows the protection state.

Result: unprotected season page goes from 72 to 48 API calls. The
remaining calls are from other Jellyfin Enhanced modules (Issue Reporter,
Jellyseerr, KefinTweaks), not from spoiler mode.
Based on comprehensive API audit of 21 call sites across spoiler modules:

1. Cache chapter playback position after first fetch — delayed re-scans
   at 800ms/2s now reuse cached PlaybackPositionTicks instead of
   re-fetching the same item 3 times per detail page.

2. Check episodeDataCache before fetching individual episodes —
   processEpisodeWithoutNumbers now checks core.getEpisodeData() first
   (populated by computeBoundary), only fetching on cache miss.

3. Reduce Fields in getParentSeriesId from
   SeriesId,ParentIndexNumber,IndexNumber,UserData to just SeriesId —
   the extra fields were never used by this function.

4. Pass already-fetched item from handleDetailPageMutation to
   redactEpisodeList — eliminates duplicate ApiClient.getItem() call
   for the same item on series/season detail pages.

5. Cache player overlay item data — OSD mutations no longer re-fetch
   the same item on every debounced callback during chapter navigation.
filterNewCards was racing handleDetailPageMutation — cards rendered after
the detail handler's pre-marking would be picked up by filterNewCards and
trigger per-card getParentSeriesId API calls (22 calls on a 17-card page).

Fix: filterNewCards now skips cards inside #itemDetailPage entirely. The
detail page handler (handleDetailPageMutation) owns all cards on detail
pages via redactEpisodeList/redactMovieDetailPage/markAllCardsScanned.

Cold load spoiler API calls: 24 → 3 on an unprotected season page.
…ssing

The blanket card.closest('#itemDetailPage') skip in filterNewCards
prevented ALL detail page cards from being processed, including:
- Season cards on series pages (need blur for unwatched seasons)
- Episode cards on actor/person pages (not handled by detail handler)

Fix: only skip detail page cards while core.detailPageProcessing is true
(during the async handler). Once the handler finishes, subsequent
filterNewCards calls process any remaining cards normally (season cards,
actor page cards, late-rendered content).
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