feat: Spoiler Mode — per-user spoiler protection#399
Draft
4eh5xitv6787h645ebv wants to merge 65 commits inton00bcodr:mainfrom
Draft
feat: Spoiler Mode — per-user spoiler protection#3994eh5xitv6787h645ebv wants to merge 65 commits inton00bcodr:mainfrom
4eh5xitv6787h645ebv wants to merge 65 commits inton00bcodr:mainfrom
Conversation
…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).
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.
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:
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:
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:
Detail Page Toggle
Navigate to any Series, Movie, or Collection detail page to toggle spoiler protection. The shield icon turns orange when active:
Confirmation Dialog
When clicking an active spoiler toggle, a confirmation dialog offers three options — Reveal Temporarily (countdown timer), Disable Protection (turn off), or 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:
Episode & Season Protection
On season detail pages, the system knows your watch boundary. Watched episodes display normally while unwatched episodes are redacted:
On the series page, future seasons beyond your watch progress are blurred:
Reveal All Timer
The Reveal All button temporarily shows all spoilers with an orange countdown banner. When the timer expires, everything re-redacts automatically:
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 collections (BoxSets) — enable on a collection page to blur unwatched members while showing watched ones normally:
Search Results
Search results for protected content are also redacted:
Feature List
Protection Surfaces
Reveal Controls
Presets
Settings
Auto-Enable
Admin Controls
Technical Details
spoiler-mode.json(no server-side schema changes)Files Changed
js/enhanced/spoiler-mode.jsjs/enhanced/ui.jsjs/enhanced/pausescreen.jsjs/plugin.jsjs/enhanced/icons.jsjs/arr/calendar-page.jsConfiguration/configPage.htmlConfiguration/PluginConfiguration.csConfiguration/UserConfiguration.csControllers/JellyfinEnhancedController.csImplementation Notes
This PR was developed with AI assistance (Claude). All changes have been reviewed, tested, and understood. The implementation includes:
Testing
console.warnin error handlers)