From d9338138acf269a37018d2fedc7b7daa8dce2d2d Mon Sep 17 00:00:00 2001 From: snapetech Date: Sun, 4 Oct 2026 07:34:20 +0000 Subject: [PATCH 1/4] chore: preserve accepted preview integration checkpoint --- .husky/commit-msg | 2 +- .husky/pre-commit | 4 +- AGENTS.md | 234 +- bin/check-current-batch-contract-lib.js | 2319 ++++- bin/check-current-batch-contract-lib.test.mjs | 1129 ++- bin/check-current-batch-contract.js | 1 + bin/check-refreshed-ui-style-lib.js | 2 +- bin/check-refreshed-ui-style-lib.test.mjs | 35 + bin/local-validation.mjs | 776 ++ bin/local-validation.test.mjs | 659 ++ bin/run-local-validation.mjs | 61 + cypress/e2e/discover.cy.ts | 12 +- cypress/e2e/library-discover-parity.cy.ts | 209 +- cypress/e2e/magazine-discover.cy.ts | 23 +- cypress/e2e/providers/tvdb.cy.ts | 117 +- cypress/e2e/request-fail-download.cy.ts | 16 +- cypress/e2e/request-retry.cy.ts | 29 +- cypress/e2e/request-status.cy.ts | 14 +- cypress/e2e/requests.cy.ts | 34 +- cypress/e2e/responsive-media-audit.cy.ts | 17 +- .../e2e/settings/discover-customization.cy.ts | 4 +- cypress/e2e/tv-details.cy.ts | 154 +- cypress/e2e/user/profile.cy.ts | 46 +- .../e2e/user/request-folder-defaults.cy.ts | 6 +- cypress/support/e2e.ts | 19 + .../interface-integration-checkpoint.md | 301 + .../interface-preservation-inventory.md | 126 + docs/maintainers/ui-fix-it.md | 117 + docs/maintainers/ui-forward-merge-guide.md | 107 + docs/maintainers/ui-style-standard.md | 452 +- package.json | 10 +- public/sw.js | 11 + ...0-02-filter-titles-and-watchlist-toggle.md | 9 + ...6-10-02-interface-verification-guidance.md | 9 + .../2026-10-02-native-saved-state-fixes.md | 9 + .../2026-10-02-request-icon-owner.md | 9 + .../2026-10-02-request-native-controls.md | 8 + .../2026-10-02-request-slider-loading-size.md | 9 + ...2026-10-02-series-media-server-controls.md | 9 + ...026-10-02-shared-development-validation.md | 9 + ...equest-actions-and-blocklist-preference.md | 9 + ...61002-native-account-and-card-alignment.md | 9 + release-notes/README.md | 1 + release-notes/request-retry-history.md | 9 + release-notes/series-overview-disclosure.md | 9 + release-notes/series-request-entry.md | 9 + release-notes/series-shared-style-cleanup.md | 9 + .../temporary-upstream-validation.md | 9 + scripts/build-release-assets.test.mjs | 31 +- seerr-api.yml | 359 + server/api/jellyfinEmbySavedItems.test.ts | 343 + server/api/jellyfinEmbySavedItems.ts | 353 + server/api/mediaServerCollections.ts | 96 + server/api/mediaServerSavedItem.ts | 318 + server/api/plexCollections.ts | 371 + server/api/servarr/readarr.test.ts | 7 +- server/api/tvdb/videoMetadata.test.ts | 48 +- server/entity/UserSettings.ts | 5 + .../interfaces/api/userSettingsInterfaces.ts | 9 + server/lib/availabilitySync.test.ts | 138 +- server/lib/requestStatus.ts | 44 + server/lib/scanners/plex/index.test.ts | 99 +- .../userSecurityMutation.credentials.test.ts | 192 + server/lib/userSecurityMutation.ts | 34 +- server/middleware/apiResponseCache.test.ts | 18 + server/middleware/apiResponseCache.ts | 9 + .../1791000000000-AddDetailDisclosureOrder.ts | 14 + .../1791000000000-AddDetailDisclosureOrder.ts | 14 + server/models/MediaServerCollections.ts | 36 + server/models/MediaServerSavedItem.ts | 9 + server/routes/artist.test.ts | 18 + server/routes/association.test.ts | 47 + server/routes/auth.test.ts | 31 + server/routes/book.test.ts | 31 + server/routes/index.test.ts | 41 + server/routes/movie.test.ts | 122 +- server/routes/music.test.ts | 128 + server/routes/request.test.ts | 13 + server/routes/request.ts | 2 + server/routes/settingsIndex.test.ts | 19 + server/routes/software.test.ts | 39 +- server/routes/software.ts | 12 + server/routes/tv.ts | 4 + server/routes/tvCollections.ts | 241 + server/routes/tvSavedItem.ts | 263 + server/routes/user.test.ts | 82 +- server/routes/user/usersettings.ts | 115 +- server/test/index.mts | 12 +- server/test/networkGuard.test.mjs | 147 + server/utils/detailDisclosureOrder.ts | 59 + src/components/ArtistCard/index.tsx | 24 +- src/components/ArtistDetails/index.tsx | 2 +- .../Association/AssociationBadge.tsx | 9 +- .../Association/AssociationFilters.tsx | 2 +- .../Association/AssociationWall.tsx | 2 +- src/components/Association/index.tsx | 37 +- src/components/AuthorCard/index.tsx | 24 +- src/components/AuthorDetails/index.tsx | 2 +- src/components/Blocklist/index.tsx | 354 +- .../BlocklistConfirmationModal/index.tsx | 7 +- .../BlocklistedTagsSelector/index.tsx | 14 +- src/components/BookSeriesDetails/index.tsx | 2 +- .../CollectionAssociationsButton.tsx | 2 +- .../CollectionMetadataDisclosures.tsx | 4 +- .../CollectionPlayOnDeviceButton.tsx | 2 +- .../CollectionDetails/CollectionRatings.tsx | 28 +- src/components/ComicDetails/index.tsx | 2 +- .../Common/BookFormatBadge/index.tsx | 31 +- src/components/Common/Button/index.tsx | 11 +- .../Common/ButtonWithDropdown/index.tsx | 16 +- .../Common/CardTextVisibilityToggle/index.tsx | 2 +- src/components/Common/Dropdown/index.tsx | 9 +- .../Common/FormatRequestControl/index.tsx | 10 +- src/components/Common/Header/index.tsx | 21 +- src/components/Common/ImageFader/index.tsx | 2 +- src/components/Common/ListView/index.tsx | 33 +- .../Common/LoadingSpinner/index.tsx | 31 +- .../Common/MediaTypeBadge/index.tsx | 59 +- src/components/Common/Modal/index.tsx | 29 +- .../Common/PageErrorMessage/index.tsx | 77 + .../Common/PaginationFooter/index.tsx | 50 +- .../Common/PlayOnDeviceButton/index.tsx | 2 +- .../Common/QuickConnectModal/index.tsx | 12 +- .../Common/SelectionCircle/index.tsx | 39 +- src/components/Common/SlideOver/index.tsx | 4 +- src/components/Common/StarSlashIcon.tsx | 24 + .../Common/StatusBadgeMini/index.tsx | 19 +- .../Discover/BookFormatTabs/index.tsx | 6 +- .../Discover/DiscoverBooks/index.tsx | 24 +- .../Discover/DiscoverComics/index.tsx | 14 +- .../Discover/DiscoverMagazines/index.tsx | 25 +- src/components/Discover/DiscoverMediaTabs.tsx | 6 +- .../Discover/DiscoverMovieGenre/index.tsx | 23 +- .../Discover/DiscoverMovieKeyword/index.tsx | 27 +- .../Discover/DiscoverMovieLanguage/index.tsx | 29 +- .../Discover/DiscoverMovies/index.tsx | 10 +- .../Discover/DiscoverMusic/index.tsx | 262 +- .../Discover/DiscoverNetwork/index.tsx | 22 +- .../Discover/DiscoverStudio/index.tsx | 22 +- src/components/Discover/DiscoverTv/index.tsx | 95 +- .../Discover/DiscoverTvGenre/index.tsx | 23 +- .../Discover/DiscoverTvKeyword/index.tsx | 27 +- .../Discover/DiscoverTvLanguage/index.tsx | 29 +- .../Discover/DiscoverTvUpcoming.tsx | 12 +- .../Discover/DiscoverWatchlist/index.tsx | 16 +- .../FilterPanel/CompactFilterSelect.tsx | 43 +- src/components/Discover/FilterPanel/index.tsx | 99 +- .../Discover/MediaDiscoveryControls.tsx | 76 +- src/components/Discover/MediaFilterOption.tsx | 8 +- .../Discover/MovieGenreList/index.tsx | 20 +- .../Discover/MovieGenreSlider/index.tsx | 12 +- .../Discover/NetworkSlider/index.tsx | 12 +- src/components/Discover/PersonalizedRows.tsx | 105 +- .../Discover/PinnedFilterInteraction.test.tsx | 110 + .../Discover/PinnedFilterSection.test.tsx | 176 + .../Discover/PinnedFilterSection.tsx | 102 +- .../Discover/PlexWatchlistSlider/index.tsx | 12 +- .../Discover/RecentRequestsSlider/index.tsx | 48 +- .../Discover/RecentlyAddedSlider/index.tsx | 12 +- .../Discover/StudioSlider/index.tsx | 12 +- src/components/Discover/TvGenreList/index.tsx | 20 +- .../Discover/TvGenreSlider/index.tsx | 12 +- src/components/Discover/Upcoming.tsx | 12 +- src/components/Discover/index.tsx | 14 +- .../IssueDetails/IssueComment/index.tsx | 15 +- src/components/IssueList/index.tsx | 549 +- .../IssueModal/CreateIssueModal/index.tsx | 4 +- src/components/IssueModal/index.tsx | 7 +- src/components/Layout/ThemePicker/index.tsx | 8 +- src/components/Layout/index.tsx | 30 +- src/components/Login/AddEmailModal.tsx | 12 +- .../ManageMediaActions.test.tsx | 2 +- .../ManageSlideOver/manageActions.test.mjs | 154 +- .../MediaDetails/DetailDisclosureButton.tsx | 35 +- .../MediaDetails/ExpandableCreditList.tsx | 44 +- .../MediaDetails/MediaDetailArtwork.tsx | 2 +- .../MediaServerCollectionButton.tsx | 310 + .../MediaServerWatchlistButton.tsx | 278 + .../MediaDetails/MovieSummaryCard.tsx | 1 + .../MediaDetails/ReorderableDisclosureRow.tsx | 439 + .../MediaDetails/SeasonEpisodeTree.tsx | 514 ++ .../SeriesSeasonEpisodeBrowser.tsx | 491 +- src/components/MediaDetails/VideoRatings.tsx | 88 + .../MediaDetails/disclosureLabels.test.mjs | 190 +- src/components/MediaDetails/seriesTreeData.ts | 80 + .../MediaDetails/subjectTagStyle.ts | 5 +- .../MediaSlider/ShowMoreCard/index.tsx | 121 +- src/components/MediaSlider/index.tsx | 65 +- .../MovieDetails/MovieDetailsLayout.tsx | 136 +- .../collectionDisclosure.test.mjs | 134 +- src/components/PersonCard/index.tsx | 24 +- src/components/PersonDetails/index.tsx | 4 +- src/components/RegionSelector/index.tsx | 182 +- .../RequestButton.entry.test.tsx | 209 + src/components/RequestButton/index.tsx | 43 +- src/components/RequestCard/index.tsx | 242 +- .../RequestList/RequestItem/index.tsx | 6 +- src/components/RequestList/index.tsx | 365 +- .../AdvancedOptionsDisclosureButton.tsx | 4 +- .../RequestModal/AdvancedRequester/index.tsx | 149 +- .../RequestModal/BookRequestModal.tsx | 4 +- .../RequestModal/BulkRequestModal.tsx | 7 +- .../RequestModal/MovieRequestModal.tsx | 4 +- .../RequestModal/MusicRequestModal.tsx | 9 +- .../RequestModal/RequestFooterStatus.test.tsx | 37 + .../RequestModal/RequestFooterStatus.tsx | 22 +- .../RequestModal/RequestMediaCard.tsx | 22 +- .../RequestSeasonEpisodeTree.test.tsx | 199 + .../RequestModal/RequestSeasonEpisodeTree.tsx | 171 + .../RequestModal/TvRequestModal.tree.test.tsx | 478 ++ .../RequestModal/TvRequestModal.tsx | 286 +- src/components/RequestModal/index.tsx | 7 +- .../RequestModal/requestTreeData.test.ts | 110 + .../RequestModal/requestTreeData.ts | 116 + .../RequestStatus/SoftwareRequests.tsx | 153 +- .../Requests/destructiveActions.tsx | 6 +- src/components/Requests/index.tsx | 1035 +-- .../Search/ContextualSearchFilters.tsx | 6 +- src/components/Search/index.tsx | 12 +- .../Selector/WatchProviderSelector.test.tsx | 213 + src/components/Selector/index.tsx | 96 +- src/components/ServiceWorkerSetup/sw.test.ts | 2 + .../Settings/BackIssueModal/index.tsx | 12 +- .../Settings/KapowarrModal/index.tsx | 12 +- .../Settings/LazyLibrarianModal/index.tsx | 12 +- src/components/Settings/LibraryItem.tsx | 4 +- src/components/Settings/LidarrModal/index.tsx | 12 +- src/components/Settings/MylarModal/index.tsx | 12 +- .../OverrideRule/OverrideRuleModal.tsx | 12 +- src/components/Settings/RadarrModal/index.tsx | 12 +- .../Settings/ReadarrModal/index.tsx | 12 +- .../Settings/SettingsAbout/Releases/index.tsx | 7 +- src/components/Settings/SettingsJellyfin.tsx | 1 - .../Settings/SettingsJobsCache/index.tsx | 7 +- .../Settings/SettingsLogs/index.tsx | 7 +- src/components/Settings/SettingsServices.tsx | 11 +- src/components/Settings/SonarrModal/index.tsx | 12 +- src/components/Slider/index.tsx | 153 +- src/components/StatusBadge/index.tsx | 80 +- src/components/StatusChecker/index.tsx | 7 +- src/components/TitleCard/ErrorCard.tsx | 49 +- src/components/TitleCard/Placeholder.tsx | 13 +- .../TitleCard/PosterRatingPopover.tsx | 34 +- src/components/TitleCard/index.tsx | 353 +- .../TvDetails/SeriesDetailsLayout.tsx | 1114 ++- src/components/TvDetails/index.tsx | 134 +- src/components/UserList/index.tsx | 65 +- .../UserProfile/ProfileHeader/index.tsx | 8 +- .../LinkJellyfinModal.tsx | 12 +- src/components/UserProfile/index.tsx | 74 +- src/components/VisualLab/Testing.tsx | 235 + src/components/VisualLab/index.tsx | 18 +- .../detailDisclosureMediaServer.test.mjs | 181 + .../detailDisclosurePinsMutation.test.ts | 26 + src/hooks/detailDisclosurePinsMutation.ts | 3 + src/hooks/useDetailDisclosureOrder.ts | 64 + src/hooks/useDetailDisclosurePins.ts | 6 + src/hooks/useDiscover.ts | 12 +- src/hooks/useUser.ts | 2 + src/i18n/locale/en.json | 170 +- src/pages/visual-lab/testing.tsx | 6 + src/styles/blackoutBackground.test.mjs | 88 +- src/styles/buttonGeometry.test.mjs | 2235 ++++- src/styles/cardSpacing.test.mjs | 71 +- src/styles/catalogFilterOwners.test.mjs | 470 + src/styles/collectionSurface.test.mjs | 195 +- src/styles/cssContract.mjs | 38 + src/styles/cssContract.test.mjs | 45 + src/styles/detailColumnWidths.test.mjs | 125 +- src/styles/detailRows.test.mjs | 296 +- src/styles/detailSubcards.test.mjs | 244 +- src/styles/detailsDisclosure.test.mjs | 178 + src/styles/disclosureColors.test.mjs | 289 +- src/styles/disclosureOrder.test.mjs | 735 ++ src/styles/discoverHeaderSpacing.test.mjs | 429 + src/styles/filterMenuGeometry.test.mjs | 2 +- src/styles/globals.css | 7535 +++++++++++++---- src/styles/headingSemanticVerifier.mjs | 603 ++ src/styles/iconOnlyButtons.test.mjs | 171 +- src/styles/issueControls.test.mjs | 30 +- src/styles/loginButtons.test.mjs | 67 +- src/styles/mediaCardOwners.test.mjs | 154 + src/styles/mediaServerCollections.test.mjs | 536 ++ .../mediaServerCollectionsClient.test.mjs | 521 ++ src/styles/mediaServerSavedItem.test.mjs | 1077 +++ .../mediaServerSavedItemClient.test.mjs | 690 ++ src/styles/menuAndQualityStates.test.mjs | 51 +- .../missingStyleRoleReferences.test.mjs | 33 + .../nativeSavedDiagnosticsSchema.test.mjs | 40 + src/styles/overviewDisclosure.test.mjs | 309 + src/styles/plexCollections.test.mjs | 367 + src/styles/qualityRequestLabels.test.mjs | 169 +- src/styles/qualityRowSizing.test.mjs | 281 +- src/styles/requestPageNativeOwners.test.mjs | 589 ++ src/styles/requestPageShellOwners.test.mjs | 376 + src/styles/requestSelectionLayout.test.mjs | 111 + src/styles/requestSliderPlaceholder.test.mjs | 325 + src/styles/seasonEpisodeTree.test.mjs | 1653 ++++ src/styles/seriesDetailsStyle.test.mjs | 548 ++ .../seriesNativeActionsIntegration.test.mjs | 377 + src/styles/seriesTreeData.test.mjs | 141 + src/styles/seriesVisualOwners.test.mjs | 191 + src/styles/subjectTags.test.mjs | 114 +- src/styles/tailwindClassVerifier.mjs | 187 + src/styles/videoRatings.test.mjs | 276 + src/styles/visual-lab.css | 48 +- src/styles/watchlistPreview.test.mjs | 594 ++ src/styles/watchlistVisibilityAction.test.mjs | 135 + tools/validation-engine/README.md | 36 + tools/validation-engine/inventory.json | 306 + .../validation-engine-20261004.tar.gz | Bin 0 -> 786165 bytes 311 files changed, 41472 insertions(+), 7239 deletions(-) create mode 100644 bin/local-validation.mjs create mode 100644 bin/local-validation.test.mjs create mode 100644 bin/run-local-validation.mjs create mode 100644 docs/maintainers/interface-integration-checkpoint.md create mode 100644 docs/maintainers/interface-preservation-inventory.md create mode 100644 docs/maintainers/ui-fix-it.md create mode 100644 docs/maintainers/ui-forward-merge-guide.md create mode 100644 release-notes/2026-10-02-filter-titles-and-watchlist-toggle.md create mode 100644 release-notes/2026-10-02-interface-verification-guidance.md create mode 100644 release-notes/2026-10-02-native-saved-state-fixes.md create mode 100644 release-notes/2026-10-02-request-icon-owner.md create mode 100644 release-notes/2026-10-02-request-native-controls.md create mode 100644 release-notes/2026-10-02-request-slider-loading-size.md create mode 100644 release-notes/2026-10-02-series-media-server-controls.md create mode 100644 release-notes/2026-10-02-shared-development-validation.md create mode 100755 release-notes/2026-10-03-request-actions-and-blocklist-preference.md create mode 100644 release-notes/20261002-native-account-and-card-alignment.md create mode 100644 release-notes/request-retry-history.md create mode 100644 release-notes/series-overview-disclosure.md create mode 100644 release-notes/series-request-entry.md create mode 100644 release-notes/series-shared-style-cleanup.md create mode 100644 release-notes/temporary-upstream-validation.md create mode 100644 server/api/jellyfinEmbySavedItems.test.ts create mode 100644 server/api/jellyfinEmbySavedItems.ts create mode 100644 server/api/mediaServerCollections.ts create mode 100644 server/api/mediaServerSavedItem.ts create mode 100644 server/api/plexCollections.ts create mode 100644 server/lib/userSecurityMutation.credentials.test.ts create mode 100644 server/migration/postgres/1791000000000-AddDetailDisclosureOrder.ts create mode 100644 server/migration/sqlite/1791000000000-AddDetailDisclosureOrder.ts create mode 100644 server/models/MediaServerCollections.ts create mode 100644 server/models/MediaServerSavedItem.ts create mode 100644 server/routes/tvCollections.ts create mode 100644 server/routes/tvSavedItem.ts create mode 100644 server/test/networkGuard.test.mjs create mode 100644 server/utils/detailDisclosureOrder.ts create mode 100644 src/components/Common/PageErrorMessage/index.tsx create mode 100644 src/components/Common/StarSlashIcon.tsx create mode 100755 src/components/Discover/PinnedFilterInteraction.test.tsx create mode 100755 src/components/Discover/PinnedFilterSection.test.tsx create mode 100644 src/components/MediaDetails/MediaServerCollectionButton.tsx create mode 100644 src/components/MediaDetails/MediaServerWatchlistButton.tsx create mode 100644 src/components/MediaDetails/ReorderableDisclosureRow.tsx create mode 100644 src/components/MediaDetails/SeasonEpisodeTree.tsx create mode 100644 src/components/MediaDetails/VideoRatings.tsx create mode 100644 src/components/MediaDetails/seriesTreeData.ts create mode 100644 src/components/RequestButton/RequestButton.entry.test.tsx create mode 100644 src/components/RequestModal/RequestSeasonEpisodeTree.test.tsx create mode 100644 src/components/RequestModal/RequestSeasonEpisodeTree.tsx create mode 100644 src/components/RequestModal/TvRequestModal.tree.test.tsx create mode 100644 src/components/RequestModal/requestTreeData.test.ts create mode 100644 src/components/RequestModal/requestTreeData.ts create mode 100644 src/components/Selector/WatchProviderSelector.test.tsx create mode 100644 src/components/VisualLab/Testing.tsx create mode 100644 src/hooks/detailDisclosureMediaServer.test.mjs create mode 100644 src/hooks/useDetailDisclosureOrder.ts create mode 100644 src/pages/visual-lab/testing.tsx create mode 100644 src/styles/catalogFilterOwners.test.mjs create mode 100644 src/styles/cssContract.mjs create mode 100644 src/styles/cssContract.test.mjs create mode 100644 src/styles/disclosureOrder.test.mjs create mode 100644 src/styles/discoverHeaderSpacing.test.mjs create mode 100644 src/styles/headingSemanticVerifier.mjs create mode 100644 src/styles/mediaCardOwners.test.mjs create mode 100644 src/styles/mediaServerCollections.test.mjs create mode 100644 src/styles/mediaServerCollectionsClient.test.mjs create mode 100644 src/styles/mediaServerSavedItem.test.mjs create mode 100644 src/styles/mediaServerSavedItemClient.test.mjs create mode 100644 src/styles/missingStyleRoleReferences.test.mjs create mode 100644 src/styles/nativeSavedDiagnosticsSchema.test.mjs create mode 100644 src/styles/overviewDisclosure.test.mjs create mode 100644 src/styles/plexCollections.test.mjs create mode 100644 src/styles/requestPageNativeOwners.test.mjs create mode 100644 src/styles/requestPageShellOwners.test.mjs create mode 100644 src/styles/requestSelectionLayout.test.mjs create mode 100644 src/styles/requestSliderPlaceholder.test.mjs create mode 100644 src/styles/seasonEpisodeTree.test.mjs create mode 100644 src/styles/seriesDetailsStyle.test.mjs create mode 100644 src/styles/seriesNativeActionsIntegration.test.mjs create mode 100644 src/styles/seriesTreeData.test.mjs create mode 100644 src/styles/seriesVisualOwners.test.mjs create mode 100644 src/styles/tailwindClassVerifier.mjs create mode 100644 src/styles/videoRatings.test.mjs create mode 100644 src/styles/watchlistPreview.test.mjs create mode 100755 src/styles/watchlistVisibilityAction.test.mjs create mode 100644 tools/validation-engine/README.md create mode 100644 tools/validation-engine/inventory.json create mode 100644 tools/validation-engine/validation-engine-20261004.tar.gz diff --git a/.husky/commit-msg b/.husky/commit-msg index 522117da1..825756ddd 100755 --- a/.husky/commit-msg +++ b/.husky/commit-msg @@ -1 +1 @@ -[ -n "$HUSKY_BYPASS" ] || npx commitlint --edit $1 +[ -n "$HUSKY_BYPASS" ] || pnpm exec commitlint --edit "$1" diff --git a/.husky/pre-commit b/.husky/pre-commit index aa09e513c..1b3339f95 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,2 +1,2 @@ -[ -n "$HUSKY_BYPASS" ] || pnpm attribution:check -npx lint-staged +[ -n "$HUSKY_BYPASS" ] || pnpm attribution:check || exit $? +pnpm exec lint-staged || exit $? diff --git a/AGENTS.md b/AGENTS.md index ecc9ab6ff..369c05da2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,79 +1,173 @@ -# Agent instructions - seerrng +# Agent Instructions — SeerrNG -## Communication style +## Existing communication contract -These interaction rules are standard for all model interfaces used with this repo, including Hermes, Codex CLI, Claude CLI, Kilo CLI, OpenCode, Cursor, and similar agents: +These rules apply to all model interfaces using this repository: - Never praise questions or validate premises before answers. -- If the user is wrong, say so immediately and directly. -- Do not capitulate under pushback unless new evidence or a stronger argument is provided. -- Do not anchor on numbers or estimates provided by the user. Generate an independent assessment first, then compare. -- Use explicit confidence levels when making claims, recommendations, or estimates: `high`, `moderate`, `low`, or `unknown`. -- Do not add disclaimers. -- Do not give ethics lectures unless explicitly asked. -- Do not use "it is important to consider" style hedges. -- Surface negative conclusions and bad news directly. -- Optimize for accuracy, not approval. +- Correct mistaken premises directly; do not capitulate without new evidence. +- Assess numbers independently instead of anchoring on a supplied estimate. +- Use explicit confidence levels for claims, recommendations and estimates: + `high`, `moderate`, `low`, or `unknown`. +- Do not add disclaimers, unsolicited ethics lectures, or formulaic hedges. +- Surface negative conclusions directly; optimize for accuracy, not approval. - If you do not know, say so. Never fabricate. -## User interface work - -- Before changing user-facing layout, controls, or styling, read - [`docs/maintainers/ui-style-standard.md`](./docs/maintainers/ui-style-standard.md) - completely. Follow its shared component, layout, interaction, and review - requirements; do not substitute an older chat summary for the file. -- Use [`src/styles/globals.css`](./src/styles/globals.css) as the executable - source for reusable visual styling. Components should refer to semantic CSS - classes there. Local utility classes may express structural or responsive - layout, but do not put reusable size, spacing, color, surface, border, or - effect values in component markup or inline `style` props. Keep data-driven - geometry only where a static CSS class cannot represent the value. -- Keep the style standard and global CSS synchronized when a shared visual rule - changes. Do not create a page-specific override or a conflicting component - size to work around either source. -- Verify shared styling changes in the existing development preview at desktop - and narrow widths before calling them accepted. Do not create a pull request - until John explicitly asks for one. -- Do not implement visual styling with inline Tailwind utility strings or - arbitrary-value Tailwind classes. Tailwind utilities in components are - limited to structural layout and responsive placement that the visual - standard explicitly permits. -- Keep these class systems independent: standard application buttons use - `app-button*`, poster overlays use `poster-control*`, and filter/sort controls - use `app-filter-button`. Copy approved visual properties into the appropriate - class family; never make one family borrow another family's class. -- When a new reusable visual treatment is needed, add or extend a semantic - class in the shared stylesheet, document it in the visual standard, and - update the style-contract validator before using it in components. -- Before finalizing UI work, run `pnpm ui-style:check` and - `pnpm current-batch:check`. A successful build does not replace visual review. +## Communication and authority + +Answer accurately, directly, and without invented evidence. Say when a result is +unknown. Distinguish implemented code, automated verification, human visual +acceptance, and live integration verification. Give numbered review items. +Do not claim a build or source assertion proves the rendered interface works. +Read and follow `CONTRIBUTING.md`; preserve attribution and disclose AI assistance. +Human review remains required. Never publish, merge, or deploy without the +maintainer's applicable authorization. + +## Required development reading + +Before implementation or merge conflict resolution, read these complete files: + +1. `docs/maintainers/ui-style-standard.md` — established asset appearance and + interaction standards. +2. `docs/maintainers/ui-fix-it.md` — scope, audit, repair, verification, and + evidence procedure. This complements the standard; it does not replace it. +3. For forward integration, `docs/maintainers/ui-forward-merge-guide.md`. + +Read applicable existing task/security/contribution instructions too. These +instructions supplement existing functional, security, migration, and release +requirements; a visual change never authorizes bypassing them. Keep this file a +router, not a second copy of the standards. Resolve conflicting decisions from +documented current acceptance, not a retired trial or whichever branch wins. + +## Shared ownership + +`src/styles/globals.css` owns reusable appearance and layout. Components select +descriptive semantic asset roles and independently named configuration variables. +Do not add Tailwind presentation OR structural utilities to migrated assets or +new UI; page/card/poster/table layout belongs to the same shared semantic system. +Do not copy values already owned by a role, invent utility-alias classes, use +fixed inline presentation, or restore retired owners. Runtime measurements may +use documented custom properties when static CSS cannot express the data. +Existing legacy consumers are unfinished audit work, not permission to add drift. +Keep application, poster, and filter control owners distinct while reusing tokens. + +Change CSS, consumers, standards, and regression checks together. Trace all +rendered consumers affected by a shared change. Preserve existing permissions, +provider identity, selected quality, selection ordering, recovery, and user data. +Missing rules or conflicting approved appearances require a maintainer decision; +do not choose a new design merely to make a check pass. + +## Required verification + +Work in approved page/asset batches. During editing, run affected focused checks +and record a preview as an iteration, not a release candidate. Use the reviewed +combined test engine for the exact final candidate, with repository-owned tests +and supplemental checks discovered for that source revision. Use the same +source-specific scope on contributor and maintainer sides. The retired archived +comprehensive suite is not a second mandatory prebuild run. Compilation follows +passing tests and is a separate action; build guards remain required. Existing +public commands and commit hooks are not changed by these instructions. Inspect +their actual bindings and reconcile obsolete gate instructions explicitly before +integration; do not disable hooks or bypass a failure. Avoid repeating a complete +suite or production compile for an unchanged candidate merely because two stages +invoke it; report any still-required duplicate binding rather than hiding it. +Follow the fix-it audit too: prose instructions are not executable tests. +Inspect the plan/inventory for connected native/source/DOM/style suites; report +actual execution, counts, skips and exclusions separately from discovery. +Before the cumulative run, follow the fix-it prerequisite procedure: verify a +complete pinned repository/snapshot and the native tooling required by its tests. +An app source volume, Linux platform or discovery plan alone does not prove that +workflow/release fixtures and native tools are available. +Do not treat unrun suites as passing, or partial failure output as success. + +A page-by-page audit does not narrow the contribution's preservation scope. +Retain accepted Request-page and shared title/heading, page-status/spinner, +button, poster and layout work alongside Series changes. Trace affected shared +consumers, but do not turn visual cleanup into an unrelated backend repair +mission. Record an unrelated failure, stop finalization, and request direction +before expanding implementation scope. See the integration checkpoint for the +preservation inventory, current evidence and pending gates. + +Checkpoint: `docs/maintainers/interface-integration-checkpoint.md`. + +Fix failed rules at their source. Do not skip tests, weaken assertions, alter +standards, disable hooks, or add blanket exclusions to obtain a green result. +When an accepted design supersedes an old check, replace that check with an +equally meaningful current behavioral/role check and document the reason. +Record genuine pre-existing failures and stop finalization until they are +resolved. A maintainer may defer work, but a deferred required failure is not a +passing gate or permission to claim the final candidate complete. + +Build and check the exact final source, using the pinned repository runtime and +lockfile. Run affected integration/e2e checks in disposable environments where +available. Never aim tests at live configuration, accounts, queues, playlists, +collections, watchlists, or databases. A mocked provider pass is not a live +round-trip pass. Perform desktop/narrow and interaction review of changed roles; +John's visual acceptance remains a release gate for this interface work. + +## Test-engine maintenance for contributors and maintainers + +Read `tools/validation-engine/README.md` and the extracted engine setup guide +before using the saved engine. This is a preserved reusable implementation and +reference packet, not an automatically installed package command. Extract outside +test discovery paths. Do not copy archived test files over the chosen source. + +The engine runs the repository's existing test files through their compatible +runners; tests do not need rewriting into an engine-specific format. On every +preview change or upstream merge: + +1. Identify changed behavior and all affected test owners, fixtures, mocks, + selectors and shared contracts. Inspect workflow, package and runner discovery + too; a test not used by GitHub can still be required local coverage. +2. Maintain affected tests and fixtures with the implementation. Preserve valid + canonical GitHub assertions. When an approved behavior supersedes an obsolete + local expectation, document the replacement and retain meaningful positive + and negative coverage. Never change application styling or weaken a test merely + to obtain a pass. Update the Fix-it guide for a proven reusable failure mode. +3. Refresh and review discovery, ownership, expected case identities, dependency + impact mappings and source/lock/runtime/recipe pins for the actual candidate. + Add newly introduced tests; remove duplicates only with proved equivalent + coverage on our supplemental side. Do not reuse reference file counts, pass + receipts or a previous revision's inventory as current acceptance. +4. Run focused affected checks during development. For final verification, queue + independent files concurrently using detected effective CPU capacity and a + sealed worker budget, dependency-aware priorities and staggered setup types. + Preserve timing-sensitive/global-state barriers and per-file disposable + fixtures. Share only proven immutable cached inputs, never mutable databases, + mocks or test state. Unknown setup requirements keep the conservative path. +5. Queue genuine failures for an authorized agent or maintainer to diagnose and + repair. The engine does not itself invent fixes. Coordinate conflicting writes + per file, preserve unrelated edits, verify base/after hashes, then freeze a new + candidate and rerun failed tests plus transitive affected checks. Retain green + results only when complete input closures prove them unchanged; unknown impact + requires broader verification. Record raw failures and actual retest receipts. +6. Compile the unchanged passing candidate once at the authorized build gate. + Keep CSS/i18n/security/network guards and separate browser, CodeQL, platform, + packaging and deployment gates visible. A test-engine pass is not the entire + GitHub pipeline. Report files, cases, failures, skips, elapsed time, worker + budget, repairs and remaining gates; visual approval occurs during development + and review, not an exhaustive visual crawl before every compile. + +The saved 3.48.1 reference packet does not establish acceptance of this preview or +a later merge. A version-specific compiler experiment is optional, not authority +to patch dependencies or transplant configuration into a different revision. + +## Safe collaboration and records + +Recommend a helper when an independent task can proceed while the user reviews +other work. Give each helper non-overlapping file ownership and the authoritative +source target; root reviews and verifies integration. Helpers do not independently +publish or mutate live services. Preserve a recoverable source checkpoint before +merging, and maintain a ledger of changes, decisions, checks, pending acceptance, +and recovery identities. No credentials or runtime backups belong in a PR. ## Release-note contract -Do not let user-facing changes reach a release without a user-facing note. -For every feature, bug fix, security change, operational behavior change, or -user-facing documentation change, add one new validated fragment under -`release-notes/` using the format documented in -[`release-notes/README.md`](./release-notes/README.md). Write what changes for -the person running or using SeerrNG, not only the implementation detail. -Every fragment must capture its audience, product area, required action (or -`none`), and breaking-change status so the release can be understood without -reading the commit history. Preview the resulting text with -`pnpm release-notes:preview --base --head ` before finalizing work. - -If the work is genuinely internal-only, explicitly mark the pull request -`release-note: none`. The pull-request template and CI enforce this choice; -never silently omit it. Release-note fragments are append-only, so add a new -file rather than changing a fragment that has already shipped. - -Before declaring a release complete, verify that the generated notes appear in -the GitHub release body and the Discord announcement. The release workflow -assembles curated fragments first and includes the git-cliff technical history -afterward. - -Keep `CHANGELOG.md` append-only at the release-section level: the tag workflow -prepends the new section without replacing audited history. When changing -release history or tag preparation, run -`node scripts/check-changelog-tags.mjs`; it must cover every existing `v3.*` -tag. The historical audit and the deliberate absence of `v3.2.6` are documented -in [`docs/maintainers/release-history-audit.md`](./docs/maintainers/release-history-audit.md). +Every user-facing feature, fix, security, operational, or documentation change +needs a new structured fragment under `release-notes/`, following +`release-notes/README.md`. Preserve shipped fragments and append-only release +history. Preview notes with `pnpm release-notes:preview --base --head `. +Internal-only work must explicitly select `release-note: none` under the existing +PR contract. Before declaring a release complete, verify notes reach the GitHub +release and announcement. Existing attribution and release-history checks remain +required; changing tag history also requires `node scripts/check-changelog-tags.mjs`. diff --git a/bin/check-current-batch-contract-lib.js b/bin/check-current-batch-contract-lib.js index 8a69fea20..8ada6742e 100644 --- a/bin/check-current-batch-contract-lib.js +++ b/bin/check-current-batch-contract-lib.js @@ -2,6 +2,1122 @@ const fs = require('node:fs'); const path = require('node:path'); +const parsedCssOwner = (stylesheet, selector, expected, media) => { + try { + const normalize = (value) => value.replace(/\s+/g, ' ').trim(); + const declarations = []; + require('postcss') + .parse(stylesheet) + .walkRules((rule) => { + if ( + !rule.selectors.some( + (item) => normalize(item) === normalize(selector) + ) + ) + return; + if ( + media && + !( + rule.parent.type === 'atrule' && + rule.parent.name === 'media' && + normalize(rule.parent.params) === media + ) + ) + return; + for (const node of rule.nodes) { + if (node.type === 'atrule' && node.name === 'apply') + throw new Error('Retired utility owner'); + if (node.type === 'decl') declarations.push(node); + } + }); + return Object.entries(expected).every(([property, value]) => { + const matches = declarations.filter((item) => item.prop === property); + const competing = + property === 'background-color' + ? ['background', 'all'] + : property.startsWith('font-') || property === 'line-height' + ? ['font', 'all'] + : property === 'border-bottom' + ? [ + 'border', + 'border-color', + 'border-width', + 'border-style', + 'all', + ] + : ['all']; + return ( + matches.length === 1 && + normalize(matches[0].value) === normalize(value) && + !declarations.some((item) => competing.includes(item.prop)) + ); + }); + } catch { + return false; + } +}; + +const parsedJsx = (source) => { + const ts = require('typescript'); + const tree = ts.createSourceFile( + 'Owner.tsx', + source, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX + ); + if (tree.parseDiagnostics.length) throw new Error('Invalid JSX source'); + const elements = []; + const visit = (node) => { + if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) + elements.push(node); + ts.forEachChild(node, visit); + }; + visit(tree); + const opening = (node) => + ts.isJsxElement(node) ? node.openingElement : node; + const tag = (node) => opening(node).tagName.getText(tree); + const attr = (node, name) => + opening(node).attributes.properties.find( + (item) => ts.isJsxAttribute(item) && item.name.getText(tree) === name + )?.initializer; + const expression = (node, name) => { + const value = attr(node, name); + return value && ts.isJsxExpression(value) ? value.expression : value; + }; + const present = (node, name) => + opening(node).attributes.properties.some( + (item) => ts.isJsxAttribute(item) && item.name.getText(tree) === name + ); + const text = (node, name) => { + const value = expression(node, name); + return value && ts.isStringLiteral(value) ? value.text : undefined; + }; + const roles = (node, ...names) => + names.every((name) => + (text(node, 'className') ?? '').split(/\s+/).includes(name) + ); + const compact = (node) => node?.getText(tree).replace(/\s+/g, ''); + const within = (node, ancestor) => { + for (let current = node.parent; current; current = current.parent) + if (current === ancestor) return true; + return false; + }; + return { + ts, + tree, + elements, + tag, + attr, + expression, + present, + text, + roles, + compact, + within, + }; +}; + +// Read connected Cypress actions/assertions, not a test title or retired format +// control. One test must exercise entry, both in-dialog choices and zero writes. +const validateSeriesRequestEntryEvidence = (source) => { + try { + const { ts, tree } = parsedJsx(source); + const callsWithin = (root) => { + const calls = []; + const visit = (node) => { + if (ts.isCallExpression(node)) calls.push(node); + ts.forEachChild(node, visit); + }; + visit(root); + return calls; + }; + const chain = (node) => { + if (!node || !ts.isCallExpression(node)) return []; + if (ts.isIdentifier(node.expression)) + return [{ name: node.expression.text, args: node.arguments }]; + if (!ts.isPropertyAccessExpression(node.expression)) return []; + let owner = node.expression.expression; + while (ts.isPropertyAccessExpression(owner)) owner = owner.expression; + const prior = ts.isCallExpression(owner) + ? chain(owner) + : ts.isIdentifier(owner) && owner.text === 'cy' + ? [{ name: 'cy', args: [] }] + : []; + return prior.length + ? [...prior, { name: node.expression.name.text, args: node.arguments }] + : []; + }; + const text = (node) => + node && ts.isStringLiteral(node) ? node.text : undefined; + const has = (steps, name, first, second) => + steps.some( + (step) => + step.name === name && + (first === undefined || text(step.args[0]) === first) && + (second === undefined || text(step.args[1]) === second) + ); + const assertion = (steps, name, value) => + has(steps, 'should', name, value) || has(steps, 'and', name, value); + const label = (node, expected) => { + if (!node) return false; + if (ts.isStringLiteral(node)) return node.text === expected; + if (!ts.isRegularExpressionLiteral(node)) return false; + const match = node.text.match(/^\/(.*)\/([a-z]*)$/); + if (!match || !match[1].startsWith('^') || !match[1].endsWith('$')) + return false; + const pattern = new RegExp(match[1], match[2]); + return pattern.test(expected) && !pattern.test(`${expected} Extra`); + }; + const contains = (steps, expected) => + steps.some( + (step) => + step.name === 'contains' && + text(step.args[0]) === 'button' && + label(step.args[1], expected) + ); + const tests = callsWithin(tree).filter( + (call) => + ts.isIdentifier(call.expression) && call.expression.text === 'it' + ); + return tests.some((test) => { + const body = test.arguments.find(ts.isArrowFunction)?.body; + if (!body) return false; + const calls = callsWithin(body); + const entry = calls.find((call) => { + const steps = chain(call); + return ( + steps[0]?.name === 'cy' && + contains(steps, 'Request') && + has(steps, 'filter', ':visible') && + assertion(steps, 'be.enabled') && + steps.at(-1)?.name === 'click' + ); + }); + const dialog = calls.find((call) => { + const steps = chain(call); + return ( + has(steps, 'get', '[role="dialog"]') && + assertion(steps, 'be.visible') && + steps.at(-1)?.name === 'within' + ); + }); + if (!entry || !dialog || entry.pos >= dialog.pos) return false; + const choiceCalls = callsWithin(dialog.arguments[0]?.body ?? dialog); + const choices = choiceCalls.map(chain); + const quality = (steps, expected) => + has(steps, 'get', '[role="group"][aria-label="Quality"]') && + contains(steps, expected); + const pressed = (steps) => + steps.some( + (step) => + ['should', 'and'].includes(step.name) && + text(step.args[0]) === 'have.attr' && + text(step.args[1]) === 'aria-pressed' && + text(step.args[2]) === 'true' + ); + if ( + !choices.some((steps) => quality(steps, 'HD') && pressed(steps)) || + !choices.some( + (steps) => + quality(steps, 'HD') && has(steps, 'click') && pressed(steps) + ) || + !choices.some( + (steps) => + quality(steps, '4K') && + assertion(steps, 'be.enabled') && + has(steps, 'click') + ) || + !choices.some((steps) => quality(steps, '4K') && pressed(steps)) || + !calls.some((call) => { + const steps = chain(call); + return ( + call.pos < entry.pos && + has(steps, 'get', '[data-testid=format-request-option-standard]') && + assertion(steps, 'not.exist') + ); + }) + ) + return false; + const switch4k = choiceCalls.find((call) => { + const steps = chain(call); + return quality(steps, '4K') && has(steps, 'click'); + }); + const selected4k = choiceCalls.find( + (call) => + call.pos > switch4k.pos && + quality(chain(call), '4K') && + pressed(chain(call)) + ); + if ( + !selected4k || + !choiceCalls.some( + (call) => + call.pos < switch4k.pos && + quality(chain(call), 'HD') && + pressed(chain(call)) + ) || + !choiceCalls.some( + (call) => + call.pos > selected4k.pos && + quality(chain(call), 'HD') && + has(chain(call), 'click') && + pressed(chain(call)) + ) + ) + return false; + return calls.some((call) => { + const steps = chain(call); + const expectation = steps[0]; + const counter = expectation?.args[0]; + const zero = steps.at(-1); + if ( + expectation?.name !== 'expect' || + !counter || + !ts.isIdentifier(counter) || + zero?.name !== 'eq' || + !zero.args[0] || + !ts.isNumericLiteral(zero.args[0]) || + zero.args[0].text !== '0' || + call.pos <= dialog.pos + ) + return false; + let queuedAssertion = false; + for ( + let owner = call.parent; + owner && owner !== body; + owner = owner.parent + ) { + if (ts.isCallExpression(owner) && has(chain(owner), 'then')) + queuedAssertion = true; + } + const initialized = body.statements?.some( + (statement) => + ts.isVariableStatement(statement) && + statement.declarationList.declarations.some( + (declaration) => + ts.isIdentifier(declaration.name) && + declaration.name.text === counter.text && + declaration.initializer && + ts.isNumericLiteral(declaration.initializer) && + declaration.initializer.text === '0' + ) + ); + const observed = calls.some((intercept) => { + const observer = intercept.arguments[2]; + if ( + intercept.pos >= entry.pos || + !has(chain(intercept), 'intercept', 'POST', '/api/v1/request*') || + !observer || + !ts.isArrowFunction(observer) + ) + return false; + let incremented = false; + const scan = (node) => { + if ( + (ts.isPostfixUnaryExpression(node) || + ts.isPrefixUnaryExpression(node)) && + node.operator === ts.SyntaxKind.PlusPlusToken && + ts.isIdentifier(node.operand) && + node.operand.text === counter.text + ) + incremented = true; + if ( + ts.isBinaryExpression(node) && + node.operatorToken.kind === ts.SyntaxKind.PlusEqualsToken && + ts.isIdentifier(node.left) && + node.left.text === counter.text && + ts.isNumericLiteral(node.right) && + node.right.text === '1' + ) + incremented = true; + ts.forEachChild(node, scan); + }; + scan(observer.body); + return incremented; + }); + return initialized && observed && queuedAssertion; + }); + }); + } catch { + return false; + } +}; + +const validateRequestFolders = (source, stylesheet, aspect) => { + try { + const { elements, roles, text, expression, compact, within } = + parsedJsx(source); + const table = elements.find( + (node) => + roles(node, 'card-table') && + text(node, 'data-table-layout') === 'request-folders' + ); + if (!table) return false; + const header = elements.find( + (node) => + within(node, table) && text(node, 'data-table-part') === 'header' + ); + const rows = elements.find( + (node) => within(node, table) && text(node, 'data-table-part') === 'rows' + ); + if (!header || !rows || within(header, rows)) return false; + const base = ".card-table[data-table-layout='request-folders']"; + if (aspect === 'scroll') + return ( + roles(rows, 'scrollable-card') && + compact(expression(rows, 'data-scrollable')) === + '(serverData?.rootFolders.length??0)>5' && + parsedCssOwner(stylesheet, `${base} [data-scrollable='true']`, { + 'max-height': '8.5rem', + 'overflow-y': 'auto', + }) + ); + if (aspect === 'border') + return parsedCssOwner(stylesheet, `${base} [data-table-part='header']`, { + 'border-bottom': + 'var(--detail-divider-width) solid rgb(var(--theme-control-border) / 0.72)', + }); + return ( + parsedCssOwner(stylesheet, base, { + width: 'fit-content', + 'max-width': '100%', + '--card-table-columns': 'minmax(0, max-content) max-content', + }) && + ['header', 'rows', 'choice-row'].every((part) => + parsedCssOwner(stylesheet, `${base} [data-table-part='${part}']`, { + display: 'grid', + 'grid-column': '1 / -1', + 'grid-template-columns': 'subgrid', + }) + ) + ); + } catch { + return false; + } +}; + +const validateRequestMediaArtwork = ( + source, + artworkSource, + stylesheet, + aspect +) => { + try { + const request = parsedJsx(source); + const shell = request.elements.find( + (node) => + request.tag(node) === 'article' && + request.roles( + node, + 'media-detail-card', + 'app-card-main', + 'card-layout', + 'refreshed-card-surface' + ) + ); + if (!shell) return false; + const artwork = request.elements.find( + (node) => + request.tag(node) === 'MediaDetailArtwork' && + request.within(node, shell) + ); + const content = request.elements.find( + (node) => + request.text(node, 'data-card-part') === 'content' && + request.within(node, shell) + ); + if ( + !artwork || + !content || + request.compact(request.expression(artwork, 'src')) !== 'artwork' || + request.compact(request.expression(artwork, 'type')) !== 'artworkType' + ) + return false; + const shared = parsedJsx(artworkSource); + const layer = shared.elements.find((node) => + shared.roles(node, 'media-detail-artwork-layer') + ); + if (!layer) return false; + if (aspect === 'clip') + return ( + parsedCssOwner(stylesheet, '.media-detail-card', { + position: 'relative', + overflow: 'hidden', + }) && + parsedCssOwner(stylesheet, '.media-detail-artwork-layer', { + position: 'absolute', + inset: '0', + overflow: 'hidden', + }) + ); + if (aspect === 'crop') + return ( + shared.elements.some( + (node) => + shared.tag(node) === 'CachedImage' && + shared.within(node, layer) && + shared.roles(node, 'media-detail-artwork-image') && + shared.present(node, 'fill') + ) && + parsedCssOwner(stylesheet, '.media-detail-artwork-image', { + 'object-fit': 'cover', + }) && + parsedCssOwner( + stylesheet, + '.media-detail-artwork-layer .media-detail-artwork-image', + { 'object-position': 'var(--card-artwork-position)' } + ) && + parsedCssOwner(stylesheet, '.card-layout', { + '--card-artwork-position': 'top', + }) + ); + return ( + shared.elements.some( + (node) => + shared.roles(node, 'refreshed-artwork-scrim') && + shared.within(node, layer) + ) && + parsedCssOwner(stylesheet, '.refreshed-artwork-scrim', { + position: 'absolute', + inset: '0', + 'background-color': 'rgb(var(--theme-artwork-scrim) / 0.46)', + }) + ); + } catch { + return false; + } +}; + +const validateTvRequestCanvas = (source, stylesheet, inset) => { + try { + const jsx = parsedJsx(source); + const modal = jsx.elements.find( + (node) => + jsx.tag(node) === 'Modal' && + (jsx.text(node, 'dialogClass') ?? '') + .split(/\s+/) + .includes('request-modal-site-surface') + ); + const shell = jsx.elements.find( + (node) => + jsx.tag(node) === 'RequestMediaCard' && modal && jsx.within(node, modal) + ); + if (!modal || !shell) return false; + return inset + ? jsx.elements.some( + (node) => + jsx.within(node, shell) && + jsx.roles( + node, + 'app-card-inset', + 'refreshed-inset-surface', + 'detail-summary-card' + ) + ) + : parsedCssOwner( + stylesheet, + '.request-modal-site-surface', + { 'max-width': '64rem' }, + '(min-width: 640px)' + ); + } catch { + return false; + } +}; + +const validateTreeEligibility = (source, aspect) => { + try { + const jsx = parsedJsx(source); + const { ts, tree, elements, text, tag, expression, compact } = jsx; + const declarations = new Map(); + const scan = (node) => { + if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) + declarations.set(node.name.text, node.initializer); + ts.forEachChild(node, scan); + }; + scan(tree); + const fallback = declarations.get('isTreeEpisodeSelectable'); + if ( + !fallback || + !ts.isArrowFunction(fallback) || + compact(fallback.body) !== 'episode.selectable??episode.available' + ) + return false; + if (aspect === 'row') { + const row = elements.find( + (node) => + tag(node) === 'button' && + text(node, 'data-tree-part') === 'episode-selection' + ); + return ( + row && + compact(expression(row, 'disabled')) === + 'disabled||!isTreeEpisodeSelectable(episode)' + ); + } + if (aspect === 'guard') { + const toggle = declarations.get('toggleEpisode'); + return ( + toggle && + ts.isArrowFunction(toggle) && + ts.isBlock(toggle.body) && + toggle.body.statements.some( + (node) => + ts.isIfStatement(node) && + compact(node.expression) === '!isTreeEpisodeSelectable(episode)' && + ts.isReturnStatement(node.thenStatement) && + !node.thenStatement.expression + ) + ); + } + if (aspect === 'all') { + const control = elements.find( + (node) => + tag(node) === 'SelectionCircle' && + compact(expression(node, 'onClick')) === 'toggleAll' + ); + return ( + control && + compact(expression(control, 'disabled')) === + 'disabled||availableIds.size===0' + ); + } + return ['seasonSelection', 'toggleSeasonSelection', 'availableIds'].every( + (name) => { + const owner = declarations.get(name); + let filtered = false; + const visit = (node) => { + if ( + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + node.expression.name.text === 'filter' && + node.arguments.length === 1 && + ts.isIdentifier(node.arguments[0]) && + node.arguments[0].text === 'isTreeEpisodeSelectable' + ) + filtered = true; + ts.forEachChild(node, visit); + }; + if (owner) visit(owner); + return filtered; + } + ); + } catch { + return false; + } +}; + +const isProviderSelectionIndicator = (source, stylesheet) => { + try { + const jsx = parsedJsx(source); + const button = jsx.elements.find((node) => jsx.tag(node) === 'button'); + if ( + !button || + !jsx.roles(button, 'provider-container') || + jsx.compact(jsx.expression(button, 'aria-pressed')) !== 'isActive' || + jsx.compact(jsx.expression(button, 'data-selected')) !== 'isActive' || + jsx.compact(jsx.expression(button, 'onClick')) !== + '()=>toggleProvider(provider.id)' || + jsx.compact(jsx.expression(button, 'aria-label')) !== 'provider.name' + ) + return false; + const logo = jsx.elements.find( + (node) => + jsx.text(node, 'data-provider-region') === 'logo' && + jsx.within(node, button) + ); + const check = jsx.elements.find( + (node) => + jsx.text(node, 'data-provider-region') === 'check' && + jsx.within(node, button) + ); + if ( + !logo || + !check || + jsx.tag(check) !== 'div' || + !jsx.elements.some( + (node) => jsx.tag(node) === 'CachedImage' && jsx.within(node, logo) + ) + ) + return false; + const conditional = check.parent?.parent; + if ( + !conditional || + !jsx.ts.isBinaryExpression(conditional) || + conditional.operatorToken.kind !== + jsx.ts.SyntaxKind.AmpersandAmpersandToken || + jsx.compact(conditional.left) !== 'isActive' + ) + return false; + const icons = jsx.elements.filter( + (node) => jsx.tag(node) === 'CheckCircleIcon' && jsx.within(node, button) + ); + return ( + icons.length === 1 && + jsx.within(icons[0], check) && + parsedCssOwner( + stylesheet, + ".provider-container > [data-provider-region='check']", + { 'pointer-events': 'none', position: 'absolute' } + ) && + parsedCssOwner( + stylesheet, + ".provider-container [data-provider-region='logo'] img", + { 'object-fit': 'contain' } + ) + ); + } catch { + return false; + } +}; + +// Inspect the actual section descriptors/children instead of retired heading or +// Tailwind strings. A declaration elsewhere must not mask a missing panel. +const readPinnedFilterSections = (source) => { + const ts = require('typescript'); + const tree = ts.createSourceFile( + 'FilterConsumer.tsx', + source, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX + ); + if (tree.parseDiagnostics.length) throw new Error('Invalid filter consumer'); + const unwrap = (node) => { + while ( + node && + (ts.isParenthesizedExpression(node) || ts.isJsxExpression(node)) + ) + node = node.expression; + return node; + }; + const attribute = (node, name) => + node.attributes.properties.find( + (item) => ts.isJsxAttribute(item) && item.name.getText(tree) === name + )?.initializer; + const property = (node, name) => + node.properties.find( + (item) => + ts.isPropertyAssignment(item) && + item.name.getText(tree).replace(/['"]/g, '') === name + )?.initializer; + const sections = new Map(); + const add = (name, children, label) => { + name = unwrap(name); + if ( + !name || + !ts.isStringLiteral(name) || + sections.has(name.text) || + !unwrap(label) || + !children + ) + throw new Error('Missing or duplicate pinned filter section'); + let rendered = false; + const visit = (node) => { + if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) { + const opening = ts.isJsxElement(node) ? node.openingElement : node; + if (opening.tagName.getText(tree) !== 'PinnedFilterSection') + rendered = true; + } + ts.forEachChild(node, visit); + }; + visit(children); + if (!rendered) throw new Error('Missing pinned filter content'); + sections.set(name.text, { children: unwrap(children), tree }); + }; + const visit = (node) => { + if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) { + const opening = ts.isJsxElement(node) ? node.openingElement : node; + const tag = opening.tagName.getText(tree); + if (tag === 'PinnedFilterSectionGroup') { + if (!unwrap(attribute(opening, 'mediaType'))) + throw new Error('Missing pin context'); + const entries = unwrap(attribute(opening, 'sections')); + if (!entries || !ts.isArrayLiteralExpression(entries)) + throw new Error('Unresolved filter section descriptors'); + for (const entry of entries.elements) { + if (!ts.isObjectLiteralExpression(entry)) + throw new Error('Unresolved filter section descriptor'); + add( + property(entry, 'section'), + property(entry, 'children'), + property(entry, 'label') + ); + } + } else if (tag === 'PinnedFilterSection') { + if (!ts.isJsxElement(node) || !unwrap(attribute(opening, 'mediaType'))) + throw new Error('Missing pin context or panel'); + add(attribute(opening, 'section'), node, attribute(opening, 'label')); + } + } + ts.forEachChild(node, visit); + }; + visit(tree); + return sections; +}; + +const validatePinnedFilterSections = (source, required) => { + try { + const sections = readPinnedFilterSections(source); + return required.every((name) => sections.has(name)); + } catch { + return false; + } +}; + +const validateNativeFilterGeometry = (stylesheet) => { + try { + const css = require('postcss').parse(stylesheet); + const owners = new Map([ + [ + '.app-filter-segment-focus', + new Map([ + ['display', 'flex'], + ['height', '100%'], + ['align-items', 'center'], + ['padding-inline', 'var(--button-padding-x)'], + ['column-gap', 'var(--button-content-gap)'], + ]), + ], + [ + '.app-filter-search-control', + new Map([ + ['max-width', '100%'], + ['flex', 'none'], + ['align-self', 'center'], + ]), + ], + [ + '.app-filter-row', + new Map([ + ['display', 'flex'], + ['flex-wrap', 'wrap'], + ['align-items', 'center'], + ['gap', '5px'], + ]), + ], + [ + '.app-pinned-filter-section', + new Map([ + ['margin-top', '0'], + ['margin-bottom', '20px'], + ]), + ], + ['.app-pinned-filter-panel', new Map([['margin-top', '12px']])], + ]); + for (const [selector, expected] of owners) { + const declarations = []; + css.walkRules((rule) => { + if (!rule.selectors.includes(selector)) return; + for (const node of rule.nodes) { + if (node.type === 'atrule' && node.name === 'apply') + throw new Error('Utility-owned filter geometry'); + if (node.type === 'decl') declarations.push(node); + } + }); + for (const [property, value] of expected) { + const values = declarations.filter((node) => node.prop === property); + if (values.length !== 1 || values[0].value !== value) + throw new Error('Missing or competing shared filter geometry'); + } + const competing = + selector === '.app-filter-segment-focus' + ? ['padding', 'padding-left', 'padding-right', 'gap', 'all'] + : selector === '.app-filter-search-control' || + selector === '.app-filter-row' + ? ['all'] + : ['margin', 'all']; + if (declarations.some((node) => competing.includes(node.prop))) + throw new Error('Competing shorthand geometry'); + } + css.walkRules((rule) => { + if (rule.selectors.includes('.app-filter-button > button')) + rule.walkDecls((decl) => { + if (/^(padding|gap|column-gap)(-|$)/.test(decl.prop)) + throw new Error('Retired descendant spacing owner'); + }); + }); + return true; + } catch { + return false; + } +}; + +const validateMusicFilterLayout = (source) => { + try { + const ts = require('typescript'); + const { children, tree } = readPinnedFilterSections(source).get('filters'); + if (!ts.isJsxElement(children)) return false; + const row = children.openingElement.attributes.properties.find( + (item) => + ts.isJsxAttribute(item) && item.name.getText(tree) === 'className' + )?.initializer; + if ( + !row || + !ts.isStringLiteral(row) || + !row.text.split(/\s+/).includes('app-filter-row') + ) + return false; + const controls = []; + const visit = (node) => { + if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) { + const opening = ts.isJsxElement(node) ? node.openingElement : node; + const tag = opening.tagName.getText(tree); + if ( + [ + 'FilterResetButton', + 'CardTextVisibilityToggle', + 'AvailabilityQualityControl', + 'MusicArtistFilter', + 'form', + 'CompactSelect', + 'MusicReleaseTypeSelect', + ].includes(tag) + ) { + const label = opening.attributes.properties.find( + (item) => + ts.isJsxAttribute(item) && item.name.getText(tree) === 'label' + )?.initializer; + const expression = + label && ts.isJsxExpression(label) ? label.expression : undefined; + const message = + expression && + ts.isCallExpression(expression) && + expression.expression.getText(tree) === 'intl.formatMessage' + ? expression.arguments[0] + : undefined; + const key = + message && + ts.isPropertyAccessExpression(message) && + ts.isIdentifier(message.expression) && + message.expression.text === 'messages' + ? message.name.text + : undefined; + controls.push(tag === 'CompactSelect' ? `${tag}:${key}` : tag); + } + } + ts.forEachChild(node, visit); + }; + visit(children); + const expected = [ + 'FilterResetButton', + 'CardTextVisibilityToggle', + 'AvailabilityQualityControl', + 'MusicArtistFilter', + 'form', + 'CompactSelect:releaseYear', + 'MusicReleaseTypeSelect', + 'CompactSelect:genres', + ]; + return ( + controls.length === expected.length && + controls.every((control, index) => control === expected[index]) + ); + } catch { + return false; + } +}; + +const validateRequestListSortDirection = (source) => { + try { + const ts = require('typescript'); + const { children, tree } = readPinnedFilterSections(source).get('sortBy'); + const matches = []; + const visit = (node) => { + if ( + ts.isJsxElement(node) && + node.openingElement.tagName.getText(tree) === 'button' + ) { + const attr = (name) => + node.openingElement.attributes.properties.find( + (item) => + ts.isJsxAttribute(item) && item.name.getText(tree) === name + )?.initializer; + let direction = false; + const inspect = (item) => { + if ( + ts.isCallExpression(item) && + item.expression.getText(tree) === 'setCurrentSortDirection' + ) + direction = true; + ts.forEachChild(item, inspect); + }; + if (attr('onClick')) inspect(attr('onClick')); + if (direction) { + const className = attr('className'); + const expression = + className && ts.isJsxExpression(className) + ? className.expression + : undefined; + matches.push( + Boolean( + expression && + ts.isCallExpression(expression) && + expression.expression.getText(tree) === + 'getFilterToggleButtonClass' && + expression.arguments.length === 1 && + expression.arguments[0].kind === ts.SyntaxKind.FalseKeyword && + attr('aria-label') && + node.parent && + ts.isJsxElement(node.parent) && + node.parent.openingElement.tagName.getText(tree) === 'Tooltip' + ) + ); + } + } + ts.forEachChild(node, visit); + }; + visit(children); + return matches.length === 1 && matches[0]; + } catch { + return false; + } +}; + +const compactRequestGeometryReason = + 'loaded and loading compact Request cards must share CSS geometry without fixed loaded height'; +const validateCompactRequestGeometry = (source, stylesheet) => { + const ts = require('typescript'); + const postcss = require('postcss'); + const tree = ts.createSourceFile( + 'RequestCard.tsx', + source, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX + ); + const roots = []; + const attr = (node, name) => + node.attributes.properties.find( + (item) => ts.isJsxAttribute(item) && item.name.getText(tree) === name + ); + const text = (node) => { + if (ts.isJsxExpression(node)) return text(node.expression); + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) + return node.text; + if (ts.isTemplateExpression(node)) + return ( + node.head.text + + node.templateSpans + .map((span) => text(span.expression) + span.literal.text) + .join('') + ); + if ( + ts.isConditionalExpression(node) && + ts.isIdentifier(node.condition) && + node.condition.text === 'compact' + ) + return text(node.whenTrue); + throw new Error('Unresolved compact root classes'); + }; + try { + const visit = (node) => { + if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) { + const className = attr(node, 'className'); + if (className?.initializer) { + let tokens; + try { + tokens = text(className.initializer).split(/\s+/).filter(Boolean); + } catch { + tokens = []; + } + const loaded = + attr(node, 'data-testid')?.initializer?.text === 'request-card'; + const placeholder = tokens.includes('request-card-placeholder'); + if (loaded || placeholder) + roots.push({ node, tokens, loaded, placeholder }); + } + } + ts.forEachChild(node, visit); + }; + const scanComponents = (node) => { + if ( + ts.isVariableDeclaration(node) && + ts.isIdentifier(node.name) && + ['RequestCard', 'RequestCardPlaceholder'].includes(node.name.text) && + node.initializer && + ts.isArrowFunction(node.initializer) + ) { + visit(node.initializer.body); + } else { + ts.forEachChild(node, scanComponents); + } + }; + scanComponents(tree); + if ( + !roots.some((root) => root.loaded) || + !roots.some((root) => root.placeholder) + ) + return false; + for (const root of roots) { + if (!root.tokens.includes('request-card-compact-layout')) return false; + if ( + root.tokens.some((token) => + /^(?:[a-z]+:)*!?(?:w-72|w-96|h-\[9\.5rem\]|min-h-0)$/.test(token) + ) + ) + return false; + const style = attr(root.node, 'style'); + if (style?.initializer) { + let geometry = false; + const inspect = (node) => { + if ( + ts.isPropertyAssignment(node) && + /^(?:width|height|minWidth|minHeight|maxWidth|maxHeight)$/.test( + node.name.getText(tree).replace(/['"]/g, '') + ) + ) + geometry = true; + ts.forEachChild(node, inspect); + }; + inspect(style.initializer); + if (geometry) return false; + } + } + const css = postcss.parse(stylesheet); + const declarations = (selector, property) => { + const values = []; + css.walkRules((rule) => { + if (rule.selectors.includes(selector)) + rule.walkDecls(property, (decl) => values.push(decl.value)); + }); + return values; + }; + const exactly = (selector, property, value) => { + const values = declarations(selector, property); + return values.length === 1 && values[0] === value; + }; + return ( + exactly( + '.request-card-compact-layout', + 'width', + 'var(--request-card-compact-width)' + ) && + exactly( + '.request-card-compact-layout', + 'min-height', + 'var(--request-card-compact-height)' + ) && + exactly( + '.request-card-placeholder', + 'height', + 'var(--request-card-compact-height)' + ) && + ['height', 'max-height', 'overflow'].every( + (property) => + declarations('.request-card-compact-layout', property).length === 0 + ) && + ['width', 'min-width', 'max-width', 'min-height', 'max-height'].every( + (property) => + declarations('.request-card-placeholder', property).length === 0 + ) + ); + } catch { + return false; + } +}; + const readRepositoryFiles = (root, fileNames) => Object.fromEntries( fileNames.map((fileName) => [ @@ -31,15 +1147,29 @@ const validateCurrentBatchContract = (files) => { }; // Shared roles can belong to comma-separated selector groups. Check the // owning declaration block, not an unrelated occurrence elsewhere in CSS. + let cssBlocks; const requireCssRule = (selector, declarations, reason) => { - const source = requireFile('src/styles/globals.css'); - const blocks = [...source.matchAll(/([^{}]+)\{([^{}]*)\}/g)]; - const matches = blocks.filter(([, selectors]) => - selectors.split(',').some((value) => value.trim() === selector) + if (!cssBlocks) { + const source = requireFile('src/styles/globals.css').replace( + /\/\*[\s\S]*?\*\//g, + '' + ); + cssBlocks = [...source.matchAll(/([^{}]+)\{([^{}]*)\}/g)]; + } + const normalize = (value) => + value + .replace(/\s+/g, ' ') + .replace(/\(\s+/g, '(') + .replace(/\s+\)/g, ')') + .trim(); + const matches = cssBlocks.filter(([, selectors]) => + selectors + .split(',') + .some((value) => normalize(value) === normalize(selector)) ); if ( - !matches.some(([, , body]) => - declarations.every((text) => body.includes(text)) + !declarations.every((text) => + matches.some(([, , body]) => normalize(body).includes(normalize(text))) ) ) { errors.push(`src/styles/globals.css: ${reason}`); @@ -325,28 +1455,28 @@ const validateCurrentBatchContract = (files) => { ); requireText( 'docs/maintainers/ui-style-standard.md', - 'Request Status uses one wrapping Task Filters row in this exact order', - 'the style standard must preserve the single wrapping Request Status task row' + 'Filter section controls sit above the section they control, with shared spacing and wrapping.', + 'the style standard must govern filter section placement and wrapping' ); requireText( 'docs/maintainers/ui-style-standard.md', - 'separate media type into a dedicated `Media Filters` section', - 'the style standard must preserve workflow-page Media Filters sections' + 'Keep media/context/state filters distinct.', + 'the style standard must distinguish media, context, and state filter roles' ); requireText( 'docs/maintainers/ui-style-standard.md', - 'On Books, `All Books` is not a reset control', - 'the style standard must keep All Books distinct from Clear Filters' + 'Clearing must clear both pending/debounced and active state', + 'the style standard must govern truthful filter reset behavior' ); requireText( 'docs/maintainers/ui-style-standard.md', - 'Request cards show approval state in the right details group', - 'the style standard must keep Approval in the right request-details group' + 'UI actions and count/status displays must reflect real eligibility and permissions.', + 'the style standard must preserve truthful status and action eligibility' ); requireText( 'docs/maintainers/ui-style-standard.md', - 'Remove duplicate approval text beside `Advanced Options`', - 'the style standard must reject duplicate Approval beside Advanced Options' + 'Status badges are classified by role and interaction', + 'the style standard must distinguish status presentation from actionable controls' ); requireText( 'docs/maintainers/site-visual-audit-2026-09-11.md', @@ -588,29 +1718,42 @@ const validateCurrentBatchContract = (files) => { 'the public Search contract must admit the independent music result refinement' ); requireText( - 'src/components/Layout/index.tsx', - 'className="global-search-progress-region"', - 'Global Search progress must use the shared non-collapsing title-margin region' + 'src/components/Common/LoadingSpinner/index.tsx', + 'const searching = useSearchActivity();', + 'Global Search progress must feed the shared page-status display' ); requireText( - 'src/components/Layout/index.tsx', - 'className="global-search-progress-indicator"', - 'Global Search progress must reference its shared indicator style' + 'src/components/Common/LoadingSpinner/index.tsx', + 'className="page-status"', + 'Global Search and page loading progress must reference the same page-status style' ); - requireText( - 'src/styles/globals.css', - '.global-search-progress-region {\n @apply relative flow-root;', - 'the Global Search progress region must prevent page-title margin collapse' + requireCssRule( + '.page-title-row', + [ + 'display: flex;', + 'align-items: center;', + 'justify-content: space-between;', + ], + 'page title and progress must share one aligned layout row' ); - requireText( - 'src/styles/globals.css', - '.global-search-progress-indicator {\n @apply pointer-events-none absolute top-1 left-0', - 'Global Search progress must occupy the reserved margin above the page title' + requireCssRule( + '.page-status', + [ + 'display: inline-flex;', + 'justify-content: flex-end;', + 'margin-inline-start: auto;', + ], + 'page progress must remain right justified in the shared page-title row' + ); + rejectText( + 'src/components/Layout/index.tsx', + 'global-search-progress-region', + 'Global Search progress must not restore the separate reserved-margin display' ); requireText( 'docs/maintainers/ui-style-standard.md', - 'occupies the reserved top margin above the page title and must never overlap the title', - 'the shared UI standard must preserve Search progress above page titles' + '`page-status`', + 'the shared UI standard must govern the one shared page-status asset' ); rejectText( 'src/components/Layout/SearchInput/index.tsx', @@ -639,9 +1782,10 @@ const validateCurrentBatchContract = (files) => { ); const globals = 'src/styles/globals.css'; + const seriesTree = 'src/components/MediaDetails/SeasonEpisodeTree.tsx'; requireText( 'src/components/Common/Modal/index.tsx', - 'className={`app-modal-screen-backdrop fixed top-0', + 'className="page-overlay"', 'every shared modal must reference the site-wide screen-backdrop style' ); rejectText( @@ -651,12 +1795,12 @@ const validateCurrentBatchContract = (files) => { ); requireText( globals, - '.app-modal-screen-backdrop {\n background-color: rgb(0 0 0 / 0.8);', + '.page-overlay {\n background-color: rgb(0 0 0 / 0.8);', 'the shared modal backdrop must use the approved less-transparent black layer' ); requireText( 'docs/maintainers/ui-style-standard.md', - 'single shared black screen backdrop at 80-percent opacity', + 'Overlapping screens use `page-overlay` for the established black 80% backdrop', 'the shared UI standard must preserve the site-wide modal backdrop treatment' ); requireText( @@ -720,9 +1864,31 @@ const validateCurrentBatchContract = (files) => { ); requireText( globals, - '.app-button.button-md,\n .app-button.button-standard,\n .app-button.button-sm,\n .button-md,\n .button-standard,\n .button-sm {\n @apply px-2.5 text-xs;\n box-sizing: border-box;\n height: var(--action-control-height);\n min-height: var(--action-control-height);\n max-height: var(--action-control-height);\n padding-top: 0;\n padding-bottom: 0;', - 'standard, medium, and small action buttons must enforce the 30-pixel border-box geometry in the final cascade' - ); + 'padding-inline: var(--action-control-padding-x) !important;', + 'standard action padding must resolve through the shared action-control token' + ); + for (const selector of [ + '.app-button.button-md', + '.app-button.button-standard', + '.app-button.button-sm', + '.button-md', + '.button-standard', + '.button-sm', + ]) { + requireCssRule( + selector, + [ + 'box-sizing: border-box;', + 'height: var(--action-control-height);', + 'min-height: var(--action-control-height);', + 'max-height: var(--action-control-height);', + 'padding-top: 0;', + 'padding-bottom: 0;', + 'padding-inline: var(--action-control-padding-x) !important;', + ], + 'standard, medium, and small action buttons must enforce shared border-box geometry in the final cascade' + ); + } requireCssRule( '.app-button-report-issue', [ @@ -887,7 +2053,7 @@ const validateCurrentBatchContract = (files) => { ); requireText( 'src/components/Common/StatusBadgeMini/index.tsx', - 'className={`poster-control shadow-md ${tone}`}', + ': `poster-control ${tone}`', 'poster quality states must match the rounded media-type badge silhouette' ); requireCssRule( @@ -922,13 +2088,13 @@ const validateCurrentBatchContract = (files) => { for (const [fileName, token, description] of [ [ 'src/components/Common/MediaTypeBadge/index.tsx', - 'bg-blue-700/35', - 'media-type badges must use the shared button resting transparency', + "card: 'poster-control media-type-badge-card'", + 'media-type poster badges must consume the shared poster-control surface', ], [ 'src/components/Common/BookFormatBadge/index.tsx', - 'bg-amber-700/35', - 'book-format badges must use the shared button resting transparency', + "card: 'poster-control poster-control-book-format media-type-badge-width'", + 'book-format poster badges must consume the shared poster-control surface', ], [ 'src/components/Common/Badge/index.tsx', @@ -950,23 +2116,25 @@ const validateCurrentBatchContract = (files) => { requireOrder( 'src/components/TitleCard/index.tsx', [ - 'flex w-full min-w-0 items-start justify-between gap-1', + 'data-poster-region="control-stack"', + 'data-poster-region="type-slot"', '{primaryStatusBadge && (', - 'flex w-full min-w-0 items-center justify-between gap-1', - ' { 'className="request-form-control mt-2 block h-10 w-full', 'playlist URL input must use the shared request control styling' ); - requireText( + for (const fileName of [ 'src/components/Discover/index.tsx', - '
', - 'Discover must scope its larger poster-card treatment to the home page' - ); - requireText( 'src/components/Association/index.tsx', - '
', - 'the Associations list explorer must reuse Discover poster and shelf formatting' + ]) { + rejectText( + fileName, + 'className="discover-home"', + 'ordinary browsing wrappers must not activate legacy Lab-only enlarging poster geometry' + ); + } + requireText( + 'src/components/TitleCard/index.tsx', + 'className="poster-layout title-card-shell"', + 'all browsing posters must use the shared poster layout owner' ); requireText( + 'src/components/TitleCard/index.tsx', + 'data-media-type={mediaType}', + 'poster variants must select their shared properties by media type' + ); + requireCssRule( + '.poster-layout', + ['width: var(--poster-width);', 'flex: 0 0 var(--poster-width);'], + 'poster width must remain fixed through the shared poster-width token rather than enlarge with the window' + ); + requireCssRule( + ".poster-layout [data-poster-region='frame']", + ['aspect-ratio: var(--poster-aspect-ratio);'], + 'poster frames must retain the shared aspect ratio' + ); + rejectText( globals, '.discover-home .title-card-shell', - 'Discover poster cards must retain their wider responsive sizing' + 'shared poster geometry must not restore page-specific responsive enlargement' ); - requireText( + rejectText( globals, - '.discover-home .slider-track:not(.slider-track-compact)', - 'Discover poster shelves must retain enough height for the complete card border' + '.discover-home .slider-track', + 'poster shelves must not restore the obsolete Discover-only height override' ); requireText( 'server/routes/request.test.ts', @@ -1040,12 +2228,22 @@ const validateCurrentBatchContract = (files) => { ); requireText( 'src/components/TitleCard/index.tsx', - 'pointer-events-none absolute inset-0 z-40', + 'data-poster-region="busy"', + 'poster mutation feedback must consume its shared non-interactive slot' + ); + requireCssRule( + ".poster-layout [data-poster-region='busy']", + ['pointer-events: none;', 'z-index: 40;'], 'poster mutation feedback must never intercept detail navigation' ); + requireCssRule( + ".poster-layout [data-poster-region='busy']", + ['position: absolute;', 'inset: 0;'], + 'poster mutation feedback must remain inside its poster frame' + ); requireText( globals, - '.media-rating-icon {\n @apply flex-none;\n width: var(--action-control-content-height);\n height: var(--action-control-content-height);', + '.media-rating-icon {\n flex: none;\n width: var(--action-control-content-height);\n height: var(--action-control-content-height);', 'rating icons must share the tomato height' ); requireText( @@ -1055,7 +2253,7 @@ const validateCurrentBatchContract = (files) => { ); requireText( globals, - '.media-rating-wordmark {\n @apply w-auto flex-none;\n height: var(--action-control-content-height);', + '.media-rating-wordmark {\n width: auto;\n flex: none;\n height: var(--action-control-content-height);', 'wide rating wordmarks must be optically normalized to the tomato image height' ); requireText( @@ -1107,13 +2305,13 @@ const validateCurrentBatchContract = (files) => { ); requireText( globals, - '.media-rating-row {\n @apply flex flex-nowrap items-center justify-between;\n padding-top: var(--card-spacing);\n min-height: calc(var(--action-control-height) + var(--card-spacing));', + '.media-rating-row {\n display: flex;\n flex-wrap: nowrap;\n align-items: center;\n justify-content: space-between;\n padding-top: var(--card-spacing);\n min-height: calc(var(--action-control-height) + var(--card-spacing));', 'quality selection and ratings must use the compact full-width shared row' ); requireText( globals, - '.media-rating-link {\n @apply inline-flex flex-none items-center gap-[5px] text-xs text-gray-300 hover:text-white;\n height: var(--action-control-height);', - 'rating image and value pairs must use only the shared five-pixel internal gap' + '.media-rating-link {\n display: inline-flex;\n flex: none;\n align-items: center;\n column-gap: var(--button-padding-x);', + 'rating image and value pairs must use only the shared internal spacing token' ); requireCssRule( '.media-rating-provider-link', @@ -1122,12 +2320,17 @@ const validateCurrentBatchContract = (files) => { ); requireCssRule( '.media-request-action-row', - ['flex w-full flex-nowrap items-center'], + [ + 'display: flex;', + 'width: 100%;', + 'flex-wrap: nowrap;', + 'align-items: center;', + ], 'detail Search Prowlarr and Request controls must share one non-wrapping row' ); requireCssRule( '.media-request-submit-action', - ['ml-auto', 'justify-end'], + ['margin-inline-start: auto;', 'justify-content: flex-end;'], 'detail Request controls must remain right-justified opposite Search Prowlarr' ); rejectText( @@ -1137,7 +2340,7 @@ const validateCurrentBatchContract = (files) => { ); requireText( globals, - '.media-rating-row {\n @apply flex flex-nowrap items-center justify-between;\n padding-top: var(--card-spacing);\n min-height: calc(var(--action-control-height) + var(--card-spacing));\n }', + '.media-rating-row {\n display: flex;\n flex-wrap: nowrap;\n align-items: center;\n justify-content: space-between;\n padding-top: var(--card-spacing);\n min-height: calc(var(--action-control-height) + var(--card-spacing));\n }', 'the ratings row must not add bottom spacing before the primary actions' ); requireText( @@ -1212,7 +2415,7 @@ const validateCurrentBatchContract = (files) => { } requireText( globals, - '.media-availability-cell {\n @apply flex w-full items-center justify-center justify-self-stretch;', + '.media-availability-cell {\n display: flex;\n width: 100%;\n align-items: center;\n justify-content: center;', 'availability headings and status icons must share one centered cell style' ); requireText( @@ -1221,10 +2424,15 @@ const validateCurrentBatchContract = (files) => { 'scrolling media table headers must reserve the shared thin scrollbar width' ); requireCount( - 'src/components/MediaDetails/SeriesSeasonEpisodeBrowser.tsx', - 'media-scroll-grid-header', - 2, - 'both series selector headers must reserve the same right-side space as their rows' + seriesTree, + 'className="scrollable-card"', + 1, + 'Series selection must use one shared scroll viewport rather than separate season and episode scrollers' + ); + requireOrder( + seriesTree, + ['{columnHeadings()}', '{feedback}', 'data-tree-part="viewport"'], + 'Series headings and feedback must remain outside and before the scrolling selectable rows' ); requireCount( 'src/components/MediaDetails/PlaybackTrackList.tsx', @@ -1233,7 +2441,7 @@ const validateCurrentBatchContract = (files) => { 'playback selector headers must reserve the same right-side space as their rows' ); for (const fileName of [ - 'src/components/MediaDetails/SeriesSeasonEpisodeBrowser.tsx', + seriesTree, 'src/components/MediaDetails/AlbumTrackList.tsx', 'src/components/MediaDetails/PlaybackTrackList.tsx', ]) { @@ -1248,9 +2456,13 @@ const validateCurrentBatchContract = (files) => { 'availability icons must not use standalone margin centering' ); } - requireText( + requireOrder( 'src/components/Discover/FilterPanel/index.tsx', - 'order-[13]', + [ + ' { 'src/components/MovieDetails/MovieDetailsLayout.tsx', 'className="media-rating-row"', ], - [ - 'src/components/TvDetails/SeriesDetailsLayout.tsx', - 'className="media-rating-row"', - ], [ 'src/components/MusicDetails/MusicDetailsLayout.tsx', 'className="media-rating-row"', @@ -1305,6 +2513,16 @@ const validateCurrentBatchContract = (files) => { 'the rating row must appear above the primary action row' ); } + requireOrder( + 'src/components/TvDetails/SeriesDetailsLayout.tsx', + [ + 'className="media-primary-action-row"', + '{primaryActions}', + '{reportIssueAction}', + ' { ); requireText( 'docs/maintainers/ui-style-standard.md', - 'both separators are ordinary `media-detail-column-divider` borders on the second and third groups', - 'the style standard must explicitly govern both Issue card dividers' + 'Column dividers belong to the owning column, not separate divider tracks that consume content width.', + 'the style standard must govern divider ownership independently of page examples' ); const blocklist = 'src/components/Blocklist/index.tsx'; requireCount( @@ -1377,8 +2595,8 @@ const validateCurrentBatchContract = (files) => { } requireText( 'docs/maintainers/ui-style-standard.md', - 'Blocklist cards use the identical three-group divider construction as Issue cards', - 'the style standard must explicitly govern Blocklist and Affected Episodes dividers' + 'Preserve the shared 2px divider treatment and responsive transition between stacked and side-by-side groups.', + 'the style standard must preserve responsive divider geometry' ); for (const fileName of [ 'src/components/MovieDetails/MovieDetailsLayout.tsx', @@ -1420,9 +2638,17 @@ const validateCurrentBatchContract = (files) => { 'detail primary actions must not use an auto-margin spacer' ); } - requireText( - globals, - '.selection-circle {\n @apply flex h-4 w-4 flex-none items-center justify-center rounded-full border text-transparent', + requireCssRule( + '.selection-circle', + [ + 'display: flex;', + 'width: var(--detail-row-height);', + 'height: var(--detail-row-height);', + 'align-items: center;', + 'justify-content: center;', + 'padding: 0;', + 'border-radius: 50%;', + ], 'selection circles must use the fixed global inactive geometry' ); requireText( @@ -1430,36 +2656,70 @@ const validateCurrentBatchContract = (files) => { '--theme-control-surface: 49 46 129;\n --theme-control-surface-hover: 55 48 163;\n --theme-control-border: 99 102 241;\n --theme-control-text: 199 210 254;', 'shared controls must retain the approved dark-indigo palette' ); - requireText( - globals, - 'background-color: rgb(var(--theme-control-surface) / 0.92);', + requireCssRule( + '.selection-circle', + ['--selection-circle-surface: rgb(var(--theme-control-surface) / 0.92);'], 'inactive selection circles must use the shared control surface' ); - requireText( - globals, - ".selection-circle[aria-pressed='true'] {\n @apply border-emerald-400 bg-emerald-500 text-white;", + requireCssRule( + '.selection-circle-icon circle', + [ + 'stroke: var(--selection-circle-border);', + 'fill: var(--selection-circle-surface);', + ], + 'selection glyphs must consume the shared border and surface state variables' + ); + requireCssRule( + ".selection-circle[aria-pressed='true']", + [ + '--selection-circle-border: rgb(var(--selection-color-bright));', + '--selection-circle-surface: rgb(var(--selection-color-fill));', + 'color: #fff;', + ], 'selected circles must use the established green fill and white check state' ); - requireText( - globals, - ".selection-circle[data-partial='true'] {\n @apply border-emerald-600 bg-emerald-800 text-white;", + requireCssRule( + ".selection-circle[data-partial='true']", + [ + '--selection-circle-border: rgb(var(--selection-color-partial));', + '--selection-circle-surface: rgb(var(--selection-color-dark));', + 'color: #fff;', + ], 'partially selected seasons must use the shared dark-green circle state' ); - requireText( - globals, - '.selection-circle-icon {\n @apply h-3 w-3;', + requireCssRule( + '.selection-circle-icon', + ['width: var(--detail-row-height);', 'height: var(--detail-row-height);'], 'selection-circle icon geometry must remain global' ); - requireText( - globals, - '.playback-button-label {\n @apply inline-flex min-w-0 items-center gap-[5px] leading-none;', + requireCssRule( + '.playback-button-label', + [ + 'display: inline-flex;', + 'min-width: 0;', + 'align-items: center;', + 'gap: var(--button-padding-x);', + 'line-height: 1;', + ], 'playback labels must share centered text and explicit logo spacing' ); - requireText( - globals, - 'svg.playback-provider-icon {\n @apply m-0 w-auto max-w-12 flex-none;\n height: var(--action-control-content-height);', - 'playback provider artwork must preserve full text-height sizing and intrinsic aspect ratio' - ); + for (const selector of [ + '.button-md svg.playback-provider-icon', + '.button-sm svg.playback-provider-icon', + '.button-standard svg.playback-provider-icon', + ]) { + requireCssRule( + selector, + [ + 'margin: 0;', + 'width: auto;', + 'max-width: 3rem;', + 'flex: none;', + 'height: var(--action-control-content-height);', + ], + 'playback provider artwork must preserve full text-height sizing and intrinsic aspect ratio' + ); + } requireText( globals, '.app-search-input {', @@ -1467,9 +2727,86 @@ const validateCurrentBatchContract = (files) => { ); requireText( 'package.json', - 'node bin/check-current-batch-contract.js && node bin/check-refreshed-ui-style.js', - 'the current batch gate must run the refreshed UI style-boundary validator' + '"validate:development": "node bin/run-local-validation.mjs"', + 'the optional complete validation command must retain the discovered local validation runner' + ); + // The maintainer restored the upstream public workflow. Keep visual and + // translation checks on the ordinary build, without forcing the archived + // cumulative test runner into every build, development session or commit. + let publicScripts = {}; + try { + publicScripts = JSON.parse(requireFile('package.json')).scripts ?? {}; + } catch { + errors.push('package.json: public command bindings require valid JSON'); + } + const requirePublicCommand = (name, command, reason) => { + if ( + typeof publicScripts[name] !== 'string' || + publicScripts[name].trim().replace(/\s+/g, ' ') !== command + ) + errors.push(`package.json: ${reason}`); + }; + requirePublicCommand( + 'build', + 'pnpm build:all', + 'the public build must retain the upstream build-all entry point' + ); + requirePublicCommand( + 'build:all', + 'run-p build:next build:server', + 'the public build must compile both client and server through upstream commands' + ); + requirePublicCommand( + 'build:next', + 'next build --webpack', + 'the public client build must retain the upstream Next compiler' + ); + requirePublicCommand( + 'build:server', + 'tsc --project server/tsconfig.json && copyfiles -u 2 server/templates/**/*.{html,pug} dist/templates && copyfiles -u 2 "server/i18n/locale/*.json" dist/i18n && tsc-alias -p server/tsconfig.json && node scripts/replace-server-import-aliases.mjs dist', + 'the public server build must retain upstream compilation, resources and alias resolution' + ); + requirePublicCommand( + 'prebuild', + 'pnpm i18n:check && pnpm current-batch:check', + 'the public build must fail closed on translation and current-batch checks' + ); + requirePublicCommand( + 'current-batch:check', + 'node bin/check-current-batch-contract.js && pnpm ui-style:check', + 'the current-batch command must fail closed on shared visual checks' + ); + requirePublicCommand( + 'ui-style:check', + 'node bin/check-refreshed-ui-style.js && node --test src/styles/buttonGeometry.test.mjs', + 'the shared visual command must retain style and control-geometry checks' + ); + requirePublicCommand( + 'i18n:check', + 'node bin/check-i18n.js', + 'the translation command must retain its actual validator' ); + if ( + typeof publicScripts.dev !== 'string' || + !publicScripts.dev.startsWith('nodemon ') || + !publicScripts.dev.includes( + '--project server/tsconfig.json server/index.ts' + ) + ) { + errors.push( + 'package.json: public development must retain the upstream watched server entry point' + ); + } + for (const check of [ + 'check-current-batch-contract.js', + 'check-refreshed-ui-style.js', + ]) { + requireText( + 'bin/local-validation.mjs', + check, + 'the cumulative runner must discover current-batch and shared-style checks' + ); + } requireText( 'bin/check-refreshed-ui-style-lib.test.mjs', 'rejects visual inline and embedded styles in refreshed components', @@ -1480,24 +2817,34 @@ const validateCurrentBatchContract = (files) => { '.app-filter-button-idle {', 'inactive filter controls must consume the shared blue control surface' ); - requireText( - globals, - '.app-filter-button {\n @apply relative inline-flex items-center justify-center gap-1.5', + requireCssRule( + '.app-filter-button', + [ + 'position: relative;', + 'display: inline-flex;', + 'align-items: center;', + 'justify-content: center;', + 'column-gap: var(--button-content-gap);', + ], 'filter buttons must consume the shared compact geometry' ); - requireText( - globals, - 'height: var(--compact-control-height);\n min-height: var(--compact-control-height);\n max-height: var(--compact-control-height);', - 'filter buttons must resolve through the shared 20-pixel height' + requireCssRule( + '.app-filter-button', + [ + 'height: var(--action-control-height);', + 'min-height: var(--action-control-height);', + 'max-height: var(--action-control-height);', + ], + 'filter buttons must resolve through the shared action-height token' ); requireText( globals, '.app-filter-button:focus-within,\n .app-filter-button:focus-visible {\n outline: 2px solid rgb(129 140 248);\n outline-offset: 1px;', 'filter button focus emphasis must render outside the fixed control box' ); - requireText( - globals, - '.app-filter-button-active {\n @apply border-indigo-400 bg-indigo-500 text-white;\n outline: 1px solid rgb(129 140 248);\n outline-offset: 1px;', + requireCssRule( + '.app-filter-button-active', + ['outline: 1px solid rgb(129 140 248);', 'outline-offset: 1px;'], 'selected filter emphasis must render outside the fixed control box' ); requireText( @@ -1505,9 +2852,16 @@ const validateCurrentBatchContract = (files) => { '.pinned-filter-section .detail-disclosure-pin-active {\n background-color: hsl(217 100% 20% / 0.55);', 'pinned filter section icons must use the shared dark-blue selected surface' ); - requireText( - globals, - '.discover-filter-control {\n @apply relative inline-flex max-w-full min-w-0 rounded-md border', + requireCssRule( + '.discover-filter-control', + [ + 'position: relative;', + 'display: inline-flex;', + 'max-width: 100%;', + 'min-width: 0;', + 'border-radius: var(--control-corner-radius);', + 'height: var(--action-control-height);', + ], 'filter fields and dropdowns must use the shared compact row' ); rejectText( @@ -1545,34 +2899,43 @@ const validateCurrentBatchContract = (files) => { '.discover-compact-select\n .react-select__indicator-separator {\n @apply hidden;', 'compact searchable dropdowns must not restore the oversized legacy indicator divider' ); - requireText( - 'src/components/Search/index.tsx', - ' { '.request-divider-dark.border-t {\n border-top-width: var(--detail-divider-width) !important;', 'request horizontal dividers must use the shared divider width' ); - requireText( - globals, - '.media-detail-column-divider {\n @apply mt-2 border-t pt-2;\n border-top-width: var(--detail-divider-width);', - 'detail columns must own their responsive divider border' + requireCssRule( + '.media-detail-column-divider', + [ + 'margin-top: var(--card-spacing);', + 'padding-top: var(--inset-card-padding);', + 'border-top-style: solid;', + 'border-top-width: var(--detail-divider-width);', + ], + 'detail columns must own their horizontal divider border' + ); + requireCssRule( + '.media-detail-column-divider', + [ + 'margin-top: 0;', + 'padding-top: 0;', + 'border-top-width: 0;', + 'border-left-width: var(--detail-divider-width);', + ], + 'detail columns must own their responsive vertical divider border' ); requireText( globals, @@ -1734,7 +3112,6 @@ const validateCurrentBatchContract = (files) => { ); } for (const fileName of [ - 'src/components/MediaDetails/SeriesSeasonEpisodeBrowser.tsx', 'src/components/MediaDetails/AlbumTrackList.tsx', 'src/components/MediaDetails/PlaybackTrackList.tsx', ]) { @@ -1784,17 +3161,16 @@ const validateCurrentBatchContract = (files) => { "disabledReason ?? 'This action is unavailable in the current state.'", 'every disabled shared button must expose an explanatory tooltip' ); - for (const disabledToken of [ - 'disabled:cursor-not-allowed', - 'disabled:brightness-50', - 'disabled:grayscale', - ]) { - requireText( - globals, - disabledToken, - 'disabled buttons must be darkened and use the prohibited cursor' - ); - } + for (const selector of ['.app-button:disabled', '.poster-control:disabled']) + if ( + !parsedCssOwner(requireFile(globals), selector, { + cursor: 'not-allowed', + opacity: '0.6', + }) + ) + errors.push( + `${globals}: disabled buttons must be darkened and use the prohibited cursor` + ); requireText( splitButton, 'const sharedClasses = `app-button', @@ -1814,7 +3190,7 @@ const validateCurrentBatchContract = (files) => { requireText( requestButton, ' { 'hides the 4K request action without 4K request permission', 'Movie details must test that the 4K action is hidden without permission' ); - for (const fileName of [ + requireText( 'cypress/e2e/movie-details.cy.ts', - 'cypress/e2e/tv-details.cy.ts', - ]) { - requireText( - fileName, - 'shows standard and 4K requests in one segmented control', - 'Movie and Series details must test the shared segmented request control' + 'shows standard and 4K requests in one segmented control', + 'Movie details must test the shared segmented request control' + ); + if ( + !validateSeriesRequestEntryEvidence( + requireFile('cypress/e2e/tv-details.cy.ts') + ) + ) { + errors.push( + 'cypress/e2e/tv-details.cy.ts: Series details must exercise one request-screen entry, in-dialog HD and 4K choices and observed zero submissions' ); } @@ -1997,14 +3377,24 @@ const validateCurrentBatchContract = (files) => { const credits = 'src/components/MediaDetails/ExpandableCreditList.tsx'; requireText( credits, - 'grid-cols-3', + 'data-list-layout="portrait"', 'cast and crew must render three person cards per row' ); requireText( credits, - 'max-h-[252px]', + 'data-scroll-layout="portrait"', 'cast and crew must show three rows before scrolling' ); + requireCssRule( + ".card-list[data-list-layout='portrait']", + ['--card-list-columns: repeat(3, minmax(0, 1fr));'], + 'portrait lists must retain three cards per row through their shared layout owner' + ); + requireCssRule( + ".scrollable-card[data-scroll-layout='portrait']", + ['--scroll-viewport-height: 252px;'], + 'portrait lists must retain the accepted bounded viewport through their shared scroll owner' + ); requireText( credits, '/images/camera-shy-profile-placeholder.png', @@ -2037,16 +3427,39 @@ const validateCurrentBatchContract = (files) => { 'MapPinIcon', 'detail disclosure pins must not regress to map-location icons' ); - requireText( - globals, - '.detail-disclosure-control {\n @apply inline-flex items-stretch overflow-hidden rounded-md border text-[11px] font-medium transition;', - 'Cast, Crew, and Subject Tags must use the shared dropdown control' - ); - requireText( - globals, - '.detail-disclosure-control {\n @apply inline-flex items-stretch overflow-hidden rounded-md border text-[11px] font-medium transition;\n height: var(--action-control-height);', - 'Cast, Crew, and Subject Tags must match the shared action-height surface' - ); + try { + const postcss = require('postcss'); + const layout = new Map([ + ['display', 'inline-flex'], + ['align-items', 'stretch'], + ['overflow', 'hidden'], + ['border-radius', 'var(--control-corner-radius)'], + ['border-width', '1px'], + ['border-style', 'solid'], + ['height', 'var(--action-control-height)'], + ['min-height', 'var(--action-control-height)'], + ['max-height', 'var(--action-control-height)'], + ]); + const declarations = []; + postcss.parse(requireFile(globals)).walkRules((rule) => { + if (rule.selectors.includes('.detail-disclosure-control')) + for (const node of rule.nodes) + if (node.type === 'decl') declarations.push(node); + }); + for (const [property, expected] of layout) { + const values = declarations.filter((node) => node.prop === property); + if (values.length !== 1 || values[0].value !== expected) + throw new Error('Missing or competing native disclosure geometry'); + } + if ( + declarations.some((node) => node.prop === 'border' || node.prop === 'all') + ) + throw new Error('Competing disclosure geometry shorthand'); + } catch { + errors.push( + 'src/styles/globals.css: Cast, Crew, and Subject Tags must match the shared segmented action-height surface' + ); + } requireText( globals, '--compact-control-height: 1.25rem;', @@ -2182,7 +3595,9 @@ const validateCurrentBatchContract = (files) => { ]) { requireText( fileName, - 'className="media-detail-disclosure-row', + fileName.includes('/TvDetails/') + ? ' { '(serverData?.rootFolders.length ?? 0) > 5', 'root-folder scrolling must begin only after five rows' ); - requireText( - advancedRequester, - "'scrollable-card max-h-[8.5rem] overflow-y-auto'", - 'long root-folder tables must scroll their data rows' - ); - requireText( - advancedRequester, - 'className="request-divider-dark col-span-2 mb-1 grid grid-cols-subgrid border-b px-1 pb-2"', - 'root-folder table rules must use the dark Destination Server color' - ); + for (const [aspect, reason] of [ + ['scroll', 'long root-folder tables must scroll their data rows'], + [ + 'border', + 'root-folder table rules must use the dark Destination Server color', + ], + ]) + if ( + !validateRequestFolders( + requireFile(advancedRequester), + requireFile(globals), + aspect + ) + ) + errors.push(`${advancedRequester}: ${reason}`); requireText( advancedRequester, 'className="request-listbox-control"', @@ -2405,17 +3825,16 @@ const validateCurrentBatchContract = (files) => { 'full-size request cards must use the site background gradient' ); } - for (const token of [ - 'dialogClass="request-modal-site-surface sm:max-w-5xl"', - ' { } requireText( 'docs/maintainers/ui-style-standard.md', - 'same shared blue control surface and border as the Destination Server dropdown', - 'the style standard must document the shared blue request-control treatment' + 'Semantic action colors retain their roles', + 'the style standard must govern shared semantic action palettes' ); requireText( 'docs/maintainers/current-batch-acceptance-ledger.md', @@ -2458,12 +3877,12 @@ const validateCurrentBatchContract = (files) => { ]) { requireText( fileName, - 'className="media-rating-row"', - 'must use the shared rating row' + ' { ); requireText( fileName, - 'refreshed-card-surface refreshed-detail-text relative overflow-hidden', + fileName.includes('/TvDetails/') + ? 'app-card-main card-layout refreshed-card-surface refreshed-detail-text' + : 'refreshed-card-surface refreshed-detail-text relative overflow-hidden', 'artwork must live inside the main card' ); requireText( @@ -2514,8 +3935,14 @@ const validateCurrentBatchContract = (files) => { 'main detail cards must retain the standard contained poster' ); requireText( - summaryOwner === fileName ? fileName : globals, - 'sm:grid-cols-[80px_minmax(0,1fr)]', + fileName.includes('/TvDetails/') + ? fileName + : summaryOwner === fileName + ? fileName + : globals, + fileName.includes('/TvDetails/') + ? 'app-detail-summary-grid' + : 'sm:grid-cols-[80px_minmax(0,1fr)]', 'main detail cards must retain the responsive poster and detail geometry' ); requireText( @@ -2530,12 +3957,16 @@ const validateCurrentBatchContract = (files) => { ); requireText( summaryOwner, - 'card:col-span-3 card:col-start-3', + fileName.includes('/TvDetails/') + ? 'className="card-table-value"' + : 'card:col-span-3 card:col-start-3', 'main detail card Genres value must begin in the first value column and span through the second detail group' ); requireText( summaryOwner, - 'min-w-0 break-words', + fileName.includes('/TvDetails/') + ? 'data-wrap="true"' + : 'min-w-0 break-words', 'main detail card Genres value must wrap naturally within its combined width' ); rejectText( @@ -2549,6 +3980,21 @@ const validateCurrentBatchContract = (files) => { 'detail actions must use one full-width justified wrapping row' ); } + requireCssRule( + '.card-table.detail-paired-columns > dd:nth-of-type(4)', + ['grid-column: 3 / span 3;'], + 'Series Genres must span the paired summary value groups through its shared table owner' + ); + requireCssRule( + ".card-table-value[data-wrap='true']", + ['overflow-wrap: anywhere;', 'white-space: normal;'], + 'wrapped Series summary values must use the shared table wrap treatment rather than clamping' + ); + requireText( + 'src/components/TvDetails/SeriesDetailsLayout.tsx', + 'className="card-table-value"\n data-wrap="true"\n data-testid="media-details-genres"', + 'Series Genres must attach the shared wrapping value role to the actual Genres cell' + ); const mediaQualitySelect = 'src/components/MediaDetails/MediaQualitySelect.tsx'; @@ -2563,11 +4009,15 @@ const validateCurrentBatchContract = (files) => { 'detail quality selection must use the shared green Quality-button treatment' ); for (const selector of ['.format-request-label', '.format-request-option']) { - requireCssRule( - selector, - ['inline-flex items-center'], - 'detail quality controls must vertically center their text and icons' - ); + if ( + !parsedCssOwner(requireFile(globals), selector, { + display: 'inline-flex', + 'align-items': 'center', + }) + ) + errors.push( + `${globals}: detail quality controls must vertically center their text and icons` + ); } requireText( mediaQualitySelect, @@ -2584,11 +4034,20 @@ const validateCurrentBatchContract = (files) => { 'aria-pressed={option.selected}', 'segmented quality controls must expose the selected state accessibly' ); - requireCssRule( - ".format-request-option[aria-pressed='true']:not(:disabled)", - ['bg-green-900/70 text-white'], - 'segmented quality controls must visibly highlight the selected available format' - ); + if ( + !parsedCssOwner( + requireFile(globals), + ".format-request-option[aria-pressed='true']:not(:disabled)", + { + 'background-color': 'rgb(20 83 45 / 0.7)', + color: 'white', + 'box-shadow': 'inset 0 -1px 0 currentColor', + } + ) + ) + errors.push( + `${globals}: segmented quality controls must visibly highlight the selected available format` + ); const musicLayout = 'src/components/MusicDetails/MusicDetailsLayout.tsx'; requireCssRule( @@ -2762,8 +4221,8 @@ const validateCurrentBatchContract = (files) => { 'Series IMDb fallback must use the normalized MDBList IMDb score' ); requireText( - seriesLayout, - 'getEffectiveVideoRatings(ratingData)', + 'src/components/MediaDetails/VideoRatings.tsx', + 'getEffectiveVideoRatings(props.ratings)', 'series ratings must use the source-aware shared rating adapter' ); rejectText( @@ -2806,27 +4265,31 @@ const validateCurrentBatchContract = (files) => { requireOrder( detailLayout, [ - 'className="media-rating-row"', 'className="media-primary-action-row"', - '{playbackActions?.(', '{primaryActions}', 'className="media-request-action-row"', + 'className="media-request-search-action"', '{indexerSearchAction}', + 'className="media-request-submit-action"', '{requestAction}', ], - 'Movie and Series must keep playback before Blocklist and Search Prowlarr opposite Request on the following row' + 'Movie and Series must keep acquisition search and request in their own opposing shared row' ); } requireOrder( seriesLayout, [ - 'className="media-rating-row"', + 'data-card-layout="media-server-panel"', + ' { 'className="media-rating-row"', ' { 'src/components/MediaDetails/SeriesSeasonEpisodeBrowser.tsx'; requireText( seriesBrowser, - 'const toggleSeason =', - 'series selection must support selecting all available episodes in a season' - ); - requireText( - seriesBrowser, - 'data-testid="season-list"', - 'the read-only season list must retain a stable browser-audit target' - ); - requireText( - seriesBrowser, - 'data-testid="episode-list"', - 'the read-only episode list must retain a stable browser-audit target' - ); - requireText( - seriesBrowser, - 'disabled={!playableItem}', - 'unavailable episodes must remain visible but cannot be selected' - ); - requireText( - seriesBrowser, - 'onClick={() => toggleItems(allPlayableItemIds)}', - 'the Season heading must expose a select-all control' + ' toggleItems(activeItemIds)}', - 'the Episode heading must expose a select-all control' + 'selectedTreeEpisodeIds(', + 'Series playback selection must map provider item IDs back to tree episode identities' ); requireText( seriesBrowser, - 'className="text-left"', - 'Season, Episode, and Title headings must remain left aligned' + 'treeSelectionToPlaybackIds(treeData.playbackIdsByEpisode, ids)', + 'tree selection must map only catalog-authorized episode identities to playable provider IDs' ); + for (const [aspect, reason] of [ + ['row', 'ineligible episodes must remain visible but cannot be selected'], + [ + 'guard', + 'episode selection handlers must reject ineligible episodes even if invoked directly', + ], + ]) + if (!validateTreeEligibility(requireFile(seriesTree), aspect)) + errors.push(`${seriesTree}: ${reason}`); requireText( - seriesBrowser, - 'className="text-center"', - 'episode counts and availability headings must remain centered' + seriesTree, + 'onClick={toggleAll}', + 'the shared tree heading must expose one all-seasons selection control' ); + for (const [aspect, reason] of [ + ['all', 'tree select-all must be disabled when no eligible episodes exist'], + [ + 'sets', + 'season and global selection sets must exclude ineligible episodes', + ], + ]) + if (!validateTreeEligibility(requireFile(seriesTree), aspect)) + errors.push(`${seriesTree}: ${reason}`); requireText( - seriesBrowser, + seriesTree, "from '@app/components/Common/SelectionCircle';", 'Series selection controls must consume the shared SelectionCircle component' ); @@ -2942,8 +4404,8 @@ const validateCurrentBatchContract = (files) => { const selectionCircle = 'src/components/Common/SelectionCircle/index.tsx'; requireText( selectionCircle, - "import { CheckIcon } from '@heroicons/react/24/solid';", - 'selector component must use the established solid CheckIcon' + 'const SelectionCircleGlyph = () => (', + 'selector component must render its shared selection glyph rather than import availability artwork' ); requireText( selectionCircle, @@ -2966,10 +4428,27 @@ const validateCurrentBatchContract = (files) => { 'selection circles must expose the approved partial-season state' ); requireText( - seriesBrowser, - 'partial={partiallySelected}', + seriesTree, + 'partial={state.partial}', 'Series playback season rows must show partial episode selection' ); + requireText( + seriesTree, + 'useState([])', + 'selection trees must start collapsed without changing selection' + ); + requireOrder( + seriesTree, + [ + 'data-tree-part="episode-selection"', + 'data-tree-part="selection"', + 'data-tree-part="number"', + 'data-tree-part="name"', + '', + 'data-tree-part="release-date"', + ], + 'episode selection must cover circle, number, and title while facts remain outside the shared target' + ); requireText( 'src/components/Common/SeriesSeasonEpisodeSelector.tsx', 'partial={partial}', @@ -2977,13 +4456,13 @@ const validateCurrentBatchContract = (files) => { ); requireText( 'docs/maintainers/ui-style-standard.md', - 'The outlined `CheckCircleIcon` and `XCircleIcon` are availability/status symbols only.', + 'Selection and availability are different semantics but share visible circle geometry.', 'the style standard must distinguish selection controls from availability icons' ); for (const selectorConsumer of [ 'src/components/Common/SeriesSeasonEpisodeSelector.tsx', - seriesBrowser, + seriesTree, albumTrackList, 'src/components/MediaDetails/PlaybackTrackList.tsx', 'src/components/CollectionDetails/index.tsx', @@ -3059,7 +4538,8 @@ const validateCurrentBatchContract = (files) => { buttonBlocks.some( (buttonBlock) => buttonBlock.includes('aria-pressed') && - buttonBlock.includes('CheckCircleIcon') + buttonBlock.includes('CheckCircleIcon') && + !isProviderSelectionIndicator(buttonBlock, requireFile(globals)) ) ) { errors.push( @@ -3166,11 +4646,10 @@ const validateCurrentBatchContract = (files) => { '', 'movie collections must use the same labeled scrolling list as music and series collections' ); - requireCount( - 'src/components/MediaDetails/SeriesSeasonEpisodeBrowser.tsx', - 'scrollable-card -mr-2', - 2, - 'Series season and episode scroll regions must share edge-aligned scrollbar geometry' + requireCssRule( + '.scrollable-card', + ['scrollbar-width: thin;', 'scrollbar-gutter: stable;'], + 'Series selection scrollbars must retain the shared thin stable-gutter geometry' ); requireText( 'src/utils/bookMarkdown.test.ts', @@ -3257,20 +4736,25 @@ const validateCurrentBatchContract = (files) => { ); requireText( 'docs/maintainers/ui-style-standard.md', - 'Do not replace Root Folder with a native select or page-local dropdown styling.', - 'the style standard must require Root Folder to reuse the shared request listbox' + 'Use the established shared custom select where its role requires consistent menu surfaces, checkmarks and effects', + 'the style standard must govern shared custom select ownership and semantics' ); requireText( 'docs/maintainers/ui-style-standard.md', - 'keep the status control at the left edge and History at the right edge', - 'the style standard must preserve full justification when request actions wrap' + 'Use the shared action-row justification variable for alignment differences; retain wrapping and the standard card gap.', + 'the style standard must govern justified wrapping action-row layout' ); const advanced = 'src/components/RequestModal/AdvancedRequester/index.tsx'; - requireText( - advanced, - 'grid-cols-[minmax(0,max-content)_max-content]', - 'root folder and available space columns must be adjacent and content-sized' - ); + if ( + !validateRequestFolders( + requireFile(advanced), + requireFile(globals), + 'columns' + ) + ) + errors.push( + `${advanced}: root folder and available space columns must be adjacent and content-sized` + ); requireText( advanced, 'serverData.rootFolders.map((folder)', @@ -3286,11 +4770,22 @@ const validateCurrentBatchContract = (files) => { 'invisible col-start-1 row-start-1 whitespace-nowrap', 'Requested By must fit the selected username rather than reserve space for every user' ); - requireText( - 'src/styles/globals.css', - '@apply z-[100] w-max overflow-auto', - 'request dropdown portals must sit above the z-60 modal backdrop' - ); + try { + const postcss = require('postcss'); + const indices = []; + postcss.parse(requireFile('src/styles/globals.css')).walkRules((rule) => { + if (rule.selectors.includes('.request-listbox-menu')) + for (const node of rule.nodes) + if (node.type === 'decl' && node.prop === 'z-index') + indices.push(node.value); + }); + if (indices.length !== 1 || indices[0] !== '100') + throw new Error('Lost portal stack owner'); + } catch { + errors.push( + 'src/styles/globals.css: request dropdown portals must sit above the z-60 modal backdrop' + ); + } requireText( 'src/styles/globals.css', '--anchor-max-height: calc(8 * var(--filter-option-height) + 0.5rem + 2px)', @@ -3307,21 +4802,20 @@ const validateCurrentBatchContract = (files) => { 'server-only overrides must still load the selected server quality profile' ); const requestMediaCard = 'src/components/RequestModal/RequestMediaCard.tsx'; - requireText( - requestMediaCard, - 'relative overflow-hidden rounded-xl', - 'request artwork must be clipped inside the full main card' - ); - requireText( - requestMediaCard, - 'className="object-cover object-top"', - 'request artwork must fill the full card from the top edge' - ); - requireText( - requestMediaCard, - 'className="refreshed-artwork-scrim"', - 'request artwork must use the shared scrim' - ); + for (const [aspect, reason] of [ + ['clip', 'request artwork must be clipped inside the full main card'], + ['crop', 'request artwork must fill the full card from the top edge'], + ['scrim', 'request artwork must use the shared scrim'], + ]) + if ( + !validateRequestMediaArtwork( + requireFile(requestMediaCard), + requireFile(mediaDetailArtwork), + requireFile(globals), + aspect + ) + ) + errors.push(`${requestMediaCard}: ${reason}`); const formatRequestControl = 'src/components/Common/FormatRequestControl/index.tsx'; @@ -3495,7 +4989,16 @@ const validateCurrentBatchContract = (files) => { const paginationFooter = 'src/components/Common/PaginationFooter/index.tsx'; requireText( paginationFooter, - 'grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)]', + 'className="pagination-footer"', + 'shared pagination must consume its shared layout owner' + ); + requireCssRule( + '.pagination-footer', + [ + 'display: grid;', + 'grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);', + 'align-items: center;', + ], 'shared pagination must use stable left, center, and right zones' ); requireOrder( @@ -3581,8 +5084,8 @@ const validateCurrentBatchContract = (files) => { } requireText( 'docs/maintainers/ui-style-standard.md', - 'The Users page places its controls and user list inside one shared translucent outer card', - 'the style standard must explicitly govern the refreshed Users page' + 'Main, nested and inset card arrangement: `card-layout` and the shared card families.', + 'the style standard must govern shared card arrangement rather than page-specific copies' ); const settingsLayout = 'src/components/Settings/SettingsLayout.tsx'; @@ -3673,19 +5176,19 @@ const validateCurrentBatchContract = (files) => { } requireText( 'docs/maintainers/ui-style-standard.md', - 'Every application Settings route uses one shared page shell', - 'the style standard must explicitly govern the shared Settings refresh' + '`page-layout` owns page margins and heading-container padding.', + 'the style standard must govern shared page-shell ownership' ); for (const token of [ - 'The gap from Search Settings to the main card is 20 pixels', - 'Legacy heading/body pairs must suppress the old 24- and 40-pixel margins', - 'Setting names top-align with their adjacent button, badge, selector, text field, or dropdown.', - 'nested options within one setting use the shared five-pixel gap', + 'Trace the complete spacing boundary.', + 'Parent gap, child margin, container padding and fixed-header offsets must not accidentally add together.', + "A card's layout selects its own column/row arrangement without re-owning typography or button appearance.", + 'Shared card spacing is 8px.', ]) { requireText( 'docs/maintainers/ui-style-standard.md', token, - 'the style standard must preserve Settings card continuity, alignment, and spacing' + 'the style standard must preserve shared spacing and arrangement ownership' ); } for (const token of [ @@ -4087,7 +5590,7 @@ const validateCurrentBatchContract = (files) => { ); requireText( 'src/components/Requests/index.tsx', - 'className="request-status-action-row"', + 'className="app-action-row request-status-action-row"', 'request cards must resolve wrapping action alignment through the shared global style' ); for (const filterPage of [ @@ -4105,9 +5608,15 @@ const validateCurrentBatchContract = (files) => { 'Request Status and Blocklist filters must not override the shared compact height' ); } - requireText( - globals, - '.request-status-action-row {\n @apply relative z-10 flex w-full flex-wrap items-center justify-between;\n padding-top: var(--card-spacing);', + requireCssRule( + '.app-action-row.request-status-action-row', + [ + '--action-row-justify: space-between;', + 'position: relative;', + 'z-index: 10;', + 'width: 100%;', + 'padding-top: var(--card-spacing);', + ], 'request action rows must remain fully justified' ); requireText( @@ -4361,11 +5870,16 @@ const validateCurrentBatchContract = (files) => { 'edit-request content must retain darker inset subcards' ); } - requireText( - 'src/components/RequestModal/TvRequestModal.tsx', - 'request-modal-site-surface sm:max-w-5xl', - 'Series new and pending requests must use the same site canvas as other media' - ); + if ( + !validateTvRequestCanvas( + requireFile('src/components/RequestModal/TvRequestModal.tsx'), + requireFile(globals), + false + ) + ) + errors.push( + 'src/components/RequestModal/TvRequestModal.tsx: Series new and pending requests must use the same site canvas as other media' + ); for (const token of ['action="delete"', 'action="remove"']) { requireOrder( 'src/components/Requests/index.tsx', @@ -4997,10 +6511,10 @@ const validateCurrentBatchContract = (files) => { ' { ], 'Books and Audiobooks must separate Media Filters from the continuous regular filter row' ); - requireOrder( - 'src/components/Discover/DiscoverMusic/index.tsx', - [ - 'className="discover-filter-primary-row"', - ' { ); requireText( 'docs/maintainers/ui-style-standard.md', - 'Genres is a single-value filter on Movie, Series, Music, and Book discovery.', - 'the style standard must preserve single-value Genres filtering site-wide' + 'Matching follows meaningful visible metadata, documented term/phrase behavior and the actual filter state.', + 'the style standard must preserve meaningful filtering behavior while refactoring presentation' ); requireText( 'src/components/Selector/genreOptions.test.ts', @@ -5409,11 +6917,14 @@ const validateCurrentBatchContract = (files) => { 'filter and sort buttons must remain exempt from the shared action-button shadow' ); } - requireText( - 'src/components/RequestList/index.tsx', - 'className="app-control-shadow-exempt z-40 mr-2 rounded-l-none px-3"', - 'the legacy Request List sort-direction button must remain shadow-free' - ); + if ( + !validateRequestListSortDirection( + requireFile('src/components/RequestList/index.tsx') + ) + ) + errors.push( + 'src/components/RequestList/index.tsx: Request List sort direction must retain the shared shadow-free filter owner, accessible help and direction callback inside its pinned sort panel' + ); for (const [fileName, mediaType] of [ ['src/components/MovieDetails/MovieRecommendations.tsx', 'movie'], ['src/components/MovieDetails/MovieSimilar.tsx', 'movie'], @@ -5431,19 +6942,29 @@ const validateCurrentBatchContract = (files) => { 'linked Recommendations and Similar pages must apply their visible filters and sorts' ); } - for (const token of [ - '.media-inset-heading {', - '@apply text-sm leading-5 font-semibold;', - '.media-inset-table-heading {', - '@apply text-xs leading-4 font-semibold;', - 'color: rgb(var(--theme-heading-text));', - ]) { - requireText( - globals, - token, - 'media inset and table headings must use their shared mode-aware typography' - ); - } + for (const [selector, fontSize, lineHeight] of [ + [ + '.media-inset-heading', + 'var(--card-subheading-font-size)', + 'var(--card-copy-line-height)', + ], + [ + '.media-inset-table-heading', + 'var(--card-table-font-size)', + 'var(--detail-row-height)', + ], + ]) + if ( + !parsedCssOwner(requireFile(globals), selector, { + 'font-size': fontSize, + 'line-height': lineHeight, + 'font-weight': 'var(--card-table-heading-weight)', + color: 'rgb(var(--theme-heading-text))', + }) + ) + errors.push( + `${globals}: media inset and table headings must use their shared mode-aware typography` + ); for (const fileName of [ 'src/components/MovieDetails/index.tsx', 'src/components/TvDetails/index.tsx', @@ -5466,11 +6987,23 @@ const validateCurrentBatchContract = (files) => { 'Discover customization must use the shared compact button' ); - requireText( + requireOrder( 'src/components/Requests/index.tsx', - 'grid-cols-[7rem_6rem_7.5rem_minmax(0,1fr)]', + [ + 'className="card-table app-history-grid"', + 'className="app-history-row"', + 'app-history-time', + 'app-history-time', + 'app-history-action', + 'app-history-description', + ], 'Request Status history must keep Date, Time, Action, Description columns' ); + requireCssRule( + '.app-history-grid', + ['--card-table-columns: 7rem 6rem 7.5rem minmax(0, 1fr);'], + 'request history must preserve its four shared table tracks' + ); const slider = 'src/components/Slider/index.tsx'; requireText( @@ -5480,9 +7013,14 @@ const validateCurrentBatchContract = (files) => { ); requireText( slider, - "'slider-track-compact min-h-[5.5rem]'", + "data-slider-size={compact ? 'compact' : undefined}", 'compact sliders must not reserve poster height' ); + requireCssRule( + ".slider-layout[data-slider-size='compact']", + ['--slider-min-height: 5.5rem;'], + 'compact slider height must use its shared layout variant rather than poster geometry' + ); requireText( 'src/components/Discover/RecentRequestsSlider/index.tsx', 'filter=recent&take=10&sort=added&skip=0', @@ -5504,11 +7042,16 @@ const validateCurrentBatchContract = (files) => { '', 'Request-card loading must retain its caller compact geometry' ); - requireText( - 'src/components/RequestCard/index.tsx', - "compact ? 'min-h-0' : 'min-h-[17rem]'", - 'loaded compact Request cards must contract to their content' - ); + if ( + !validateCompactRequestGeometry( + requireFile('src/components/RequestCard/index.tsx'), + requireFile('src/styles/globals.css') + ) + ) { + errors.push( + `src/components/RequestCard/index.tsx: ${compactRequestGeometryReason}` + ); + } requireText( 'src/components/RequestCard/index.tsx', 'showApprovalActions &&', @@ -5535,9 +7078,9 @@ const validateCurrentBatchContract = (files) => { 'media details must not expose a separate decline action' ); requireText( - 'src/components/MediaDetails/SeriesSeasonEpisodeBrowser.tsx', - "partial ? 'text-emerald-600' : 'text-green-400'", - 'partial season availability must use the dark emerald marker' + 'src/components/MediaDetails/seriesTreeData.ts', + 'available: Boolean(playableItem)', + 'Series tree availability must come from the exact playable catalog episode rather than an aggregate season state' ); requireText( globals, @@ -5577,18 +7120,18 @@ const validateCurrentBatchContract = (files) => { ); requireText( 'docs/maintainers/ui-style-standard.md', - 'occupies one full-width row; never place two result cards beside each other', - 'the shared UI standard must preserve the one-card-per-row Associations layout' + "A card's layout selects its own column/row arrangement without re-owning typography or button appearance.", + 'the shared UI standard must govern card arrangement separately from content styling' ); requireText( 'docs/maintainers/ui-style-standard.md', - 'Do not show a redundant Status heading or value.', - 'the shared UI standard must prohibit redundant Association status rows' + 'UI actions and count/status displays must reflect real eligibility and permissions.', + 'the shared UI standard must preserve truthful status presentation' ); requireText( 'docs/maintainers/ui-style-standard.md', - 'closes the popup as navigation begins', - 'the shared UI standard must require Associations popups to close during result navigation' + 'Links represent actual valid destinations.', + 'the shared UI standard must preserve valid navigation semantics' ); requireText( 'src/components/Discover/StudioSlider/index.tsx', diff --git a/bin/check-current-batch-contract-lib.test.mjs b/bin/check-current-batch-contract-lib.test.mjs index ddcbfddf9..4d7bc7fc6 100644 --- a/bin/check-current-batch-contract-lib.test.mjs +++ b/bin/check-current-batch-contract-lib.test.mjs @@ -8,6 +8,7 @@ const { validateCurrentBatchContract, } = require('./check-current-batch-contract-lib.js'); +const repositorySourceCache = new Map(); const repositoryFilesWith = (overrides = {}) => new Proxy(overrides, { has: (target, key) => @@ -17,10 +18,188 @@ const repositoryFilesWith = (overrides = {}) => get: (target, key) => { if (typeof key !== 'string') return undefined; if (Object.hasOwn(target, key)) return target[key]; - return readFileSync(new URL(`../${key}`, import.meta.url), 'utf8'); + if (!repositorySourceCache.has(key)) { + repositorySourceCache.set( + key, + readFileSync(new URL(`../${key}`, import.meta.url), 'utf8') + ); + } + return repositorySourceCache.get(key); }, }); +test('Series request entry evidence follows actions rather than retired test titles', () => { + const file = 'cypress/e2e/tv-details.cy.ts'; + const source = repositoryFilesWith()[file]; + const reason = + 'Series details must exercise one request-screen entry, in-dialog HD and 4K choices and observed zero submissions'; + const passes = (changed) => + !validateCurrentBatchContract( + repositoryFilesWith({ [file]: changed }) + ).some((error) => error.includes(reason)); + assert.equal(passes(source), true, 'actual entry/quality/no-write evidence'); + assert.equal( + passes( + source.replace( + 'opens one request screen and chooses HD or 4K inside it without submitting', + 'an independently named behavioral test' + ) + ), + true, + 'test title is not the contract' + ); + assert.equal( + passes(source.replaceAll('submissions', 'observedWrites')), + true, + 'counter identifier is not the contract' + ); + const mutations = [ + ['POST observer', "cy.intercept('POST'", "cy.intercept('GET'"], + ['request endpoint', "'/api/v1/request*'", "'/api/v1/unrelated*'"], + ['write observation', 'submissions += 1;', 'submissions += 0;'], + ['fresh counter', 'let submissions = 0;', 'let submissions = 1;'], + ['zero assertion', ').to.eq(0)', ').to.eq(1)'], + ['queued zero assertion', 'cy.then(() =>', 'immediateAssertion(() =>'], + ['entry action', ".filter(':visible')", ".filter(':hidden')"], + ['entry enabled', ".should('be.enabled')", ".should('be.disabled')"], + [ + 'entry click', + ".should('be.enabled')\n .click();", + ".should('be.enabled');", + ], + [ + 'dialog owner', + 'cy.get(\'[role="dialog"]\')', + 'cy.get(\'[role="region"]\')', + ], + [ + 'visible dialog', + ".should('be.visible')\n .within", + ".should('not.be.visible')\n .within", + ], + [ + 'quality owner', + '[role="group"][aria-label="Quality"]', + '[role="group"][aria-label="Other"]', + ], + ['HD choice', '/^HD$/', '/^SD$/'], + ['4K choice', '/^4K$/', '/^8K$/'], + [ + '4K choice action', + ".should('be.enabled')\n .click();", + ".should('be.enabled');", + ], + ['selected state', "'aria-pressed', 'true'", "'aria-pressed', 'false'"], + ['retired control absence', ".should('not.exist');", ".should('exist');"], + [ + 'return to HD', + ".click()\n .should('have.attr', 'aria-pressed', 'true');", + ".should('have.attr', 'aria-pressed', 'true');", + ], + ]; + for (const [edge, before, after] of mutations) { + assert.ok( + source.includes(before), + `${edge}: mutation matches actual source` + ); + assert.equal( + passes(source.replaceAll(before, after)), + false, + `${edge}: missing behavioral edge fails closed` + ); + } + const movieFile = 'cypress/e2e/movie-details.cy.ts'; + assert.ok( + validateCurrentBatchContract( + repositoryFilesWith({ + [movieFile]: repositoryFilesWith()[movieFile].replace( + 'shows standard and 4K requests in one segmented control', + 'removed Movie segmented evidence' + ), + }) + ).some((error) => + error.includes( + 'Movie details must test the shared segmented request control' + ) + ), + 'Movie segmented behavior remains required' + ); +}); + +test('upstream public commands preserve build-time translation and visual guards', () => { + const packageSource = repositoryFilesWith()['package.json']; + const baseline = validateCurrentBatchContract(repositoryFilesWith()); + assert.deepEqual( + baseline, + [], + 'the actual source passes all current contracts' + ); + const cases = [ + ['build', 'pnpm build:compile', 'upstream build-all entry point'], + ['build:all', 'run-p build:next', 'compile both client and server'], + ['build:next', 'echo skipped', 'upstream Next compiler'], + [ + 'build:server', + 'echo skipped', + 'upstream compilation, resources and alias resolution', + ], + [ + 'prebuild', + 'pnpm current-batch:check', + 'translation and current-batch checks', + ], + ['prebuild', 'pnpm i18n:check', 'translation and current-batch checks'], + [ + 'prebuild', + 'pnpm i18n:check; pnpm current-batch:check', + 'translation and current-batch checks', + ], + [ + 'current-batch:check', + 'node bin/check-current-batch-contract.js', + 'fail closed on shared visual checks', + ], + [ + 'current-batch:check', + 'node bin/check-current-batch-contract.js; pnpm ui-style:check', + 'fail closed on shared visual checks', + ], + [ + 'ui-style:check', + 'node --test src/styles/buttonGeometry.test.mjs', + 'retain style and control-geometry checks', + ], + [ + 'ui-style:check', + 'node bin/check-refreshed-ui-style.js', + 'retain style and control-geometry checks', + ], + [ + 'ui-style:check', + 'node bin/check-refreshed-ui-style.js; node --test src/styles/buttonGeometry.test.mjs', + 'retain style and control-geometry checks', + ], + [ + 'i18n:check', + 'echo skipped', + 'translation command must retain its actual validator', + ], + ['dev', 'echo skipped', 'upstream watched server entry point'], + ['prebuild', undefined, 'translation and current-batch checks'], + ['current-batch:check', false, 'fail closed on shared visual checks'], + ]; + for (const [name, replacement, reason] of cases) { + const changed = JSON.parse(packageSource); + changed.scripts[name] = replacement; + assert.ok( + validateCurrentBatchContract( + repositoryFilesWith({ 'package.json': JSON.stringify(changed) }) + ).some((error) => error.includes(reason)), + `${name}: ${reason}` + ); + } +}); + test('current shared owners pass their checks and functional mutations fail', () => { const cases = [ [ @@ -67,8 +246,8 @@ test('current shared owners pass their checks and functional mutations fail', () ], [ 'src/styles/globals.css', - '.media-request-submit-action {\n @apply ml-auto flex min-w-0 items-center justify-end;', - '.media-request-submit-action {\n @apply flex min-w-0 items-center justify-start;', + '.media-request-submit-action {\n margin-inline-start: auto;', + '.media-request-submit-action {\n margin-inline-start: 0;', 'detail Request controls must remain right-justified opposite Search Prowlarr', ], [ @@ -101,6 +280,483 @@ test('current shared owners pass their checks and functional mutations fail', () } }); +test('accepted poster, tree, and shared ownership contracts reject functional regressions', () => { + const tree = 'src/components/MediaDetails/SeasonEpisodeTree.tsx'; + const browser = 'src/components/MediaDetails/SeriesSeasonEpisodeBrowser.tsx'; + const css = 'src/styles/globals.css'; + const cases = [ + [ + css, + 'width: var(--poster-width);', + 'width: 100%;', + 'poster width must remain fixed through the shared poster-width token', + ], + [ + 'src/components/Discover/index.tsx', + '<>', + '
', + 'ordinary browsing wrappers must not activate legacy Lab-only enlarging poster geometry', + ], + [ + tree, + 'disabled={disabled || !isTreeEpisodeSelectable(episode)}', + 'disabled={false}', + 'ineligible episodes must remain visible but cannot be selected', + ], + [ + tree, + 'if (!isTreeEpisodeSelectable(episode)) return;', + '', + 'episode selection handlers must reject ineligible episodes', + ], + [ + tree, + 'partial={state.partial}', + 'partial={false}', + 'Series playback season rows must show partial episode selection', + ], + [ + tree, + 'useState([])', + 'useState([1])', + 'selection trees must start collapsed', + ], + [ + tree, + 'data-tree-part="episode-selection"', + 'data-tree-part="episode-title-only"', + 'episode selection must cover circle, number, and title', + ], + [ + tree, + '{columnHeadings()}', + '', + 'Series headings and feedback must remain outside and before the scrolling selectable rows', + ], + [ + tree, + 'className="scrollable-card"', + 'className="scrollable-card scrollable-card"', + 'Series selection must use one shared scroll viewport', + ], + [ + browser, + 'treeSelectionToPlaybackIds(treeData.playbackIdsByEpisode, ids)', + 'ids', + 'tree selection must map only catalog-authorized episode identities', + ], + [ + browser, + 'selectedTreeEpisodeIds(', + 'unsafeSelectionIds(', + 'Series playback selection must map provider item IDs back to tree episode identities', + ], + [ + 'src/components/TvDetails/SeriesDetailsLayout.tsx', + 'data-testid="media-details-genres"', + 'data-testid="unrelated-value"', + 'Series Genres must attach the shared wrapping value role to the actual Genres cell', + ], + [ + css, + 'margin-inline-start: auto;', + 'margin-inline-start: 0;', + 'page progress must remain right justified', + ], + [ + 'bin/local-validation.mjs', + "['Shared visual standard', 'bin/check-refreshed-ui-style.js']", + "['Shared visual standard', 'bin/removed-style-check.js']", + 'the cumulative runner must discover current-batch and shared-style checks', + ], + ]; + const baseline = validateCurrentBatchContract(repositoryFilesWith()); + for (const [fileName, original, replacement, reason] of cases) { + const source = repositoryFilesWith()[fileName]; + assert.ok( + source.includes(original), + `mutation target exists: ${fileName} ${original}` + ); + assert.ok( + !baseline.some((error) => error.includes(reason)), + `current owner passes: ${reason}` + ); + // Replace every same-owner occurrence so unrelated consumers cannot mask it. + const changed = source.replaceAll(original, replacement); + assert.ok( + validateCurrentBatchContract( + repositoryFilesWith({ [fileName]: changed }) + ).some((error) => error.includes(reason)), + reason + ); + } + const source = repositoryFilesWith()[tree]; + for (const heading of [ + 'export const seasonSelection =', + 'export const toggleSeasonSelection =', + 'const availableIds =', + ]) { + const offset = source.indexOf(heading); + const target = source.indexOf('.filter(isTreeEpisodeSelectable)', offset); + assert.ok(offset >= 0 && target >= offset, heading); + const changed = + source.slice(0, target) + + source + .slice(target) + .replace('.filter(isTreeEpisodeSelectable)', '.filter(() => true)'); + assert.ok( + validateCurrentBatchContract( + repositoryFilesWith({ [tree]: changed }) + ).some((error) => + error.includes( + 'season and global selection sets must exclude ineligible episodes' + ) + ), + heading + ); + } +}); + +test('accepted semantic request owners satisfy every reconciled build diagnostic', () => { + const selector = 'src/components/Selector/index.tsx'; + const errors = validateCurrentBatchContract( + repositoryFilesWith({ + [selector]: repositoryFilesWith()[selector], + }) + ); + for (const reason of [ + 'disabled buttons must be darkened and use the prohibited cursor', + 'long root-folder tables must scroll their data rows', + 'root-folder table rules must use the dark Destination Server color', + 'Request Series must reuse the shared request site canvas and inset artwork card', + 'detail quality controls must vertically center their text and icons', + 'segmented quality controls must visibly highlight the selected available format', + 'ineligible episodes must remain visible but cannot be selected', + 'episode selection handlers must reject ineligible episodes', + 'tree select-all must be disabled when no eligible episodes exist', + 'season and global selection sets must exclude ineligible episodes', + 'interactive selection controls must use SelectionCircle instead of embedding CheckCircleIcon', + 'root folder and available space columns must be adjacent and content-sized', + 'request artwork must be clipped inside the full main card', + 'request artwork must fill the full card from the top edge', + 'request artwork must use the shared scrim', + 'Series new and pending requests must use the same site canvas as other media', + 'media inset and table headings must use their shared mode-aware typography', + ]) + assert.ok(!errors.some((error) => error.includes(reason)), reason); +}); + +test('native request CSS accepts declaration order but rejects diagnostic-specific lost and competing owners', () => { + const file = 'src/styles/globals.css'; + const source = repositoryFilesWith()[file]; + const selectorFile = 'src/components/Selector/index.tsx'; + const normalize = (text) => text.replace(/\s+/g, ' ').trim(); + const fails = (css, reason) => + validateCurrentBatchContract( + repositoryFilesWith({ + [file]: css, + [selectorFile]: repositoryFilesWith()[selectorFile], + }) + ).some((error) => error.includes(reason)); + const cases = [ + ['.app-button:disabled', 'opacity', 'disabled buttons must be darkened'], + ['.poster-control:disabled', 'cursor', 'disabled buttons must be darkened'], + [ + ".card-table[data-table-layout='request-folders'] [data-scrollable='true']", + 'max-height', + 'long root-folder tables must scroll their data rows', + ], + [ + ".card-table[data-table-layout='request-folders'] [data-scrollable='true']", + 'overflow-y', + 'long root-folder tables must scroll their data rows', + ], + [ + ".card-table[data-table-layout='request-folders'] [data-table-part='header']", + 'border-bottom', + 'root-folder table rules must use the dark Destination Server color', + ], + [ + ".card-table[data-table-layout='request-folders']", + '--card-table-columns', + 'root folder and available space columns must be adjacent and content-sized', + ], + [ + ".card-table[data-table-layout='request-folders'] [data-table-part='rows']", + 'grid-template-columns', + 'root folder and available space columns must be adjacent and content-sized', + ], + [ + '.format-request-label', + 'align-items', + 'detail quality controls must vertically center', + ], + [ + '.format-request-option', + 'display', + 'detail quality controls must vertically center', + ], + [ + ".format-request-option[aria-pressed='true']:not(:disabled)", + 'background-color', + 'segmented quality controls must visibly highlight', + ], + [ + ".format-request-option[aria-pressed='true']:not(:disabled)", + 'box-shadow', + 'segmented quality controls must visibly highlight', + ], + [ + '.media-detail-card', + 'overflow', + 'request artwork must be clipped inside the full main card', + ], + [ + '.media-detail-artwork-layer', + 'inset', + 'request artwork must be clipped inside the full main card', + ], + [ + '.media-detail-artwork-image', + 'object-fit', + 'request artwork must fill the full card from the top edge', + ], + [ + '.card-layout', + '--card-artwork-position', + 'request artwork must fill the full card from the top edge', + ], + [ + '.refreshed-artwork-scrim', + 'background-color', + 'request artwork must use the shared scrim', + ], + [ + '.request-modal-site-surface', + 'max-width', + 'Series new and pending requests must use the same site canvas', + ], + [ + '.media-inset-heading', + 'font-size', + 'media inset and table headings must use their shared mode-aware typography', + ], + [ + '.media-inset-table-heading', + 'line-height', + 'media inset and table headings must use their shared mode-aware typography', + ], + [ + ".provider-container > [data-provider-region='check']", + 'pointer-events', + 'interactive selection controls must use SelectionCircle', + ], + ]; + for (const [selector, property, reason] of cases) { + const ast = require('postcss').parse(source); + let changed = 0; + ast.walkRules((rule) => { + if (!rule.selectors.some((item) => normalize(item) === selector)) return; + rule.walkDecls(property, (decl) => { + changed++; + decl.remove(); + }); + }); + assert.ok(changed, `${selector} ${property}: mutation target exists`); + assert.equal( + fails(ast.toString(), reason), + true, + `lost ${selector} ${property} fails` + ); + } + const ordered = require('postcss').parse(source); + ordered.walkRules((rule) => { + if ( + !cases.some(([selector]) => + rule.selectors.some((item) => normalize(item) === selector) + ) + ) + return; + const nodes = [...rule.nodes]; + rule.removeAll(); + for (const node of nodes.reverse()) rule.append(node); + }); + for (const [, , reason] of cases) + assert.equal( + fails(ordered.toString(), reason), + false, + `native declaration order does not alter ${reason}` + ); + for (const [rule, reason] of [ + [ + '.app-button:disabled { opacity: 1; }', + 'disabled buttons must be darkened', + ], + [ + '.format-request-label { @apply items-start; }', + 'detail quality controls must vertically center', + ], + [ + ".format-request-option[aria-pressed='true']:not(:disabled) { background: transparent; }", + 'segmented quality controls must visibly highlight', + ], + [ + ".card-table[data-table-layout='request-folders'] [data-table-part='header'] { border: 0; }", + 'root-folder table rules must use the dark Destination Server color', + ], + [ + '.media-inset-table-heading { font: inherit; }', + 'media inset and table headings must use their shared mode-aware typography', + ], + [ + '.media-detail-card { overflow: visible; }', + 'request artwork must be clipped inside the full main card', + ], + ]) + assert.equal( + fails(`${source}\n${rule}`, reason), + true, + `competing owner fails: ${rule}` + ); +}); + +test('semantic request JSX rejects lost owners, eligibility overrides, and counterfeit provider exceptions', () => { + const cases = [ + [ + 'src/components/RequestModal/AdvancedRequester/index.tsx', + 'data-table-part="rows"', + 'data-table-part="retired-rows"', + 'long root-folder tables must scroll their data rows', + ], + [ + 'src/components/RequestModal/AdvancedRequester/index.tsx', + 'data-table-part="header"', + 'data-table-part="retired-header"', + 'root-folder table rules must use the dark Destination Server color', + ], + [ + 'src/components/RequestModal/AdvancedRequester/index.tsx', + 'data-table-layout="request-folders"', + 'data-table-layout="other"', + 'root folder and available space columns must be adjacent and content-sized', + ], + [ + 'src/components/RequestModal/TvRequestModal.tsx', + 'dialogClass="request-modal-site-surface"', + 'dialogClass="legacy-canvas"', + 'Series new and pending requests must use the same site canvas', + ], + [ + 'src/components/RequestModal/TvRequestModal.tsx', + 'app-card-inset refreshed-inset-surface detail-summary-card', + 'detail-summary-card', + 'Request Series must reuse the shared request site canvas and inset artwork card', + ], + [ + 'src/components/RequestModal/RequestMediaCard.tsx', + 'app-card-main', + 'app-card-inset', + 'request artwork must be clipped inside the full main card', + ], + [ + 'src/components/RequestModal/RequestMediaCard.tsx', + 'src={artwork}', + 'src="unrelated"', + 'request artwork must fill the full card from the top edge', + ], + [ + 'src/components/MediaDetails/MediaDetailArtwork.tsx', + '\n fill\n', + '\n', + 'request artwork must fill the full card from the top edge', + ], + [ + 'src/components/MediaDetails/MediaDetailArtwork.tsx', + 'className="refreshed-artwork-scrim"', + 'className="retired-scrim"', + 'request artwork must use the shared scrim', + ], + [ + 'src/components/MediaDetails/SeasonEpisodeTree.tsx', + 'episode.selectable ?? episode.available', + 'episode.selectable || episode.available', + 'ineligible episodes must remain visible but cannot be selected', + ], + [ + 'src/components/MediaDetails/SeasonEpisodeTree.tsx', + 'disabled={disabled || availableIds.size === 0}', + 'disabled={disabled}', + 'tree select-all must be disabled when no eligible episodes exist', + ], + [ + 'src/components/Selector/index.tsx', + 'data-selected={isActive}', + 'data-selected={true}', + 'interactive selection controls must use SelectionCircle', + ], + [ + 'src/components/Selector/index.tsx', + 'data-provider-region="logo"', + 'data-provider-region="unknown"', + 'interactive selection controls must use SelectionCircle', + ], + [ + 'src/components/Selector/index.tsx', + 'onClick={() => toggleProvider(provider.id)}', + 'onClick={() => toggleProvider(0)}', + 'interactive selection controls must use SelectionCircle', + ], + [ + 'src/components/Selector/index.tsx', + '{isActive && (', + '{true && (', + 'interactive selection controls must use SelectionCircle', + ], + [ + 'src/components/Selector/index.tsx', + 'aria-label={provider.name}', + 'aria-label="unrelated"', + 'interactive selection controls must use SelectionCircle', + ], + ]; + for (const [file, original, replacement, reason] of cases) { + const source = repositoryFilesWith()[file]; + assert.ok( + source.includes(original), + `${file}: mutation target exists: ${original}` + ); + assert.ok( + !validateCurrentBatchContract( + repositoryFilesWith({ [file]: source }) + ).some((error) => error.includes(reason)), + `accepted owner passes: ${reason}` + ); + assert.ok( + validateCurrentBatchContract( + repositoryFilesWith({ + [file]: source.replaceAll(original, replacement), + }) + ).some((error) => error.includes(reason)), + `lost relationship fails: ${reason}` + ); + } + const unexpected = 'src/components/TestUnexpectedSelection.tsx'; + assert.ok( + validateCurrentBatchContract( + repositoryFilesWith({ + [unexpected]: + 'const Other = () => ;', + }) + ).some( + (error) => + error.startsWith(`${unexpected}:`) && + error.includes( + 'interactive selection controls must use SelectionCircle' + ) + ) + ); +}); + test('reports missing files instead of silently skipping contract checks', () => { const errors = validateCurrentBatchContract({}); assert.ok(errors.some((error) => error.includes('Missing contract input:'))); @@ -188,7 +844,7 @@ test('reports a shared selection-circle asset regression', () => { assert.ok( errors.some((error) => error.includes( - 'selector component must use the established solid CheckIcon' + 'selector component must render its shared selection glyph rather than import availability artwork' ) ) ); @@ -299,7 +955,7 @@ test('reports filter-control and reset regressions', () => { 'Clear Filters must restore the default sort order on every filtered page', 'Movie and Series Genres must load the complete type-specific option list', 'Movie and Series Genres must use the same shared single-value dropdown as neighboring filters', - 'the style standard must preserve single-value Genres filtering site-wide', + 'the style standard must preserve meaningful filtering behavior while refactoring presentation', ]) { assert.ok( errors.some((error) => error.includes(expected)), @@ -351,7 +1007,7 @@ test('reports refreshed Users page contract regressions', () => { 'the Users table must load one scrollable user set instead of paging the page', 'the Users page must not restore pagination, legacy table controls, native row checkboxes, or full-width actions', 'Users table geometry, typography, and divider styling must resolve through shared global classes', - 'the style standard must explicitly govern the refreshed Users page', + 'the style standard must govern shared card arrangement rather than page-specific copies', ]) { assert.ok( errors.some((error) => error.includes(expected)), @@ -379,7 +1035,7 @@ test('reports shared Settings shell and group regressions', () => { 'Settings route navigation must reuse the shared filter-button component styling', 'General Settings and Playlist Integrations must be separate standard subcards', 'Settings layout, groups, actions, and change state must resolve through shared global classes', - 'the style standard must explicitly govern the shared Settings refresh', + 'the style standard must govern shared page-shell ownership', ]) { assert.ok( errors.some((error) => error.includes(expected)), @@ -428,7 +1084,7 @@ test('reports Issue divider and compact status badge regressions', () => { 'Issue status badges must use the shared compact detail-status geometry', 'Blocklist source badges must share the compact Issue-status geometry', 'compact Issue and Blocklist badge geometry and colors must resolve through shared global classes', - 'the style standard must explicitly govern Blocklist and Affected Episodes dividers', + 'the style standard must preserve responsive divider geometry', ]) { assert.ok( errors.some((error) => error.includes(expected)), @@ -448,11 +1104,11 @@ test('reports Global Search progress positioning regressions', () => { const errors = validateCurrentBatchContract(proxy); for (const expected of [ - 'Global Search progress must use the shared non-collapsing title-margin region', - 'Global Search progress must reference its shared indicator style', - 'the Global Search progress region must prevent page-title margin collapse', - 'Global Search progress must occupy the reserved margin above the page title', - 'the shared UI standard must preserve Search progress above page titles', + 'Global Search progress must feed the shared page-status display', + 'Global Search and page loading progress must reference the same page-status style', + 'page title and progress must share one aligned layout row', + 'page progress must remain right justified in the shared page-title row', + 'the shared UI standard must govern the one shared page-status asset', ]) { assert.ok( errors.some((error) => error.includes(expected)), @@ -561,7 +1217,7 @@ test('reports poster availability control drift', () => { 'poster quality states must use the shared rounded status badge', 'poster quality states must match the rounded media-type badge silhouette', 'available poster qualities must place the outlined availability icon after the green quality label', - 'poster overlays must use independent full-width rows with primary status on row one, Associations on row two left, and secondary status on row two right', + 'poster overlays must use shared media-type, saved-item, association, quality, and watched-status slots without local layout utilities', 'music posters must preserve separate MP3 and FLAC request states', 'pending bell and processing timer badges must explain their meaning in tooltips', ]) { @@ -613,7 +1269,7 @@ test('reports request-card contrast and Advanced Options contract drift', () => for (const expected of [ 'request controls must use the shared dropdown treatment', 'request table and details dividers must use the shared theme border color', - 'detail columns must own their responsive divider border', + 'detail columns must own their responsive vertical divider border', 'detail columns must resolve through the shared divider class', 'root-folder scrolling must begin only after five rows', 'Destination Server, Metadata Profile, Quality Profile, Root Folder, and Language must all use the shared request listbox', @@ -631,8 +1287,8 @@ test('reports request-card contrast and Advanced Options contract drift', () => 'the Series request-edit Close action must use the shared red danger treatment', 'request cards must resolve wrapping action alignment through the shared global style', 'request action rows must remain fully justified', - 'the style standard must require Root Folder to reuse the shared request listbox', - 'the style standard must preserve full justification when request actions wrap', + 'the style standard must govern shared custom select ownership and semantics', + 'the style standard must govern justified wrapping action-row layout', ]) { assert.ok( errors.some((error) => error.includes(expected)), @@ -699,9 +1355,9 @@ test('reports recovered visual-contract and evidence-provenance regressions', () const errors = validateCurrentBatchContract(proxy); for (const expected of [ - 'the style standard must preserve the single wrapping Request Status task row', - 'the style standard must keep All Books distinct from Clear Filters', - 'the style standard must keep Approval in the right request-details group', + 'the style standard must govern filter section placement and wrapping', + 'the style standard must govern truthful filter reset behavior', + 'the style standard must preserve truthful status and action eligibility', 'the historical visual audit must not claim current render evidence for post-r3 source', 'Request Status task summaries must retain the approved single-row order', 'request forms must render Approval in their details grid', @@ -750,14 +1406,14 @@ test('reports refreshed Manage, Issue action, availability, and Association card 'ratings and primary actions must retain the shared card-spacing gap', 'availability headings and status icons must share one centered cell style', 'scrolling media table headers must reserve the shared thin scrollbar width', - 'both series selector headers must reserve the same right-side space as their rows', + 'Series selection must use one shared scroll viewport rather than separate season and episode scrollers', 'playback selector headers must reserve the same right-side space as their rows', 'association results must reuse the complete artwork-backed Issue card surface', 'association detail cards must retain the shared artwork, poster, and scrim block', 'association results must resolve per-title background artwork through one shared helper', 'both Issue card detail separators must use the shared owning-column border class', 'Issue cards must not reserve a standalone divider track between detail groups', - 'the style standard must explicitly govern both Issue card dividers', + 'the style standard must govern divider ownership independently of page examples', ]) { assert.ok( errors.some((error) => error.includes(expected)), @@ -794,6 +1450,16 @@ test('shared CSS checks accept grouped selectors but reject unrelated declaratio '.discover-filter-secondary-row { gap: 5px; } .other { gap: var(--card-spacing); margin-top: var(--card-spacing); }' ).some((error) => error.includes(spacing)) ); + assert.ok( + !validate( + '/* Shared owner, formatted independently. */\n.discover-filter-secondary-row,\n.other { gap:\nvar(--card-spacing); }\n.discover-filter-secondary-row { margin-top: var(--card-spacing); }' + ).some((error) => error.includes(spacing)) + ); + assert.ok( + validate( + '.discover-filter-secondary-row { gap: var(--card-spacing); } /* .discover-filter-secondary-row { margin-top: var(--card-spacing); } */' + ).some((error) => error.includes(spacing)) + ); }); test('reports related-media controls, inset-heading, and duplicate icon-gap regressions', () => { @@ -818,7 +1484,7 @@ test('reports related-media controls, inset-heading, and duplicate icon-gap regr 'media inset and table headings must use their shared mode-aware typography', 'detail action labels must not duplicate the shared button icon gap', 'quality selection and ratings must use the compact full-width shared row', - 'rating image and value pairs must use only the shared five-pixel internal gap', + 'rating image and value pairs must use only the shared internal spacing token', 'playback controls must not be nested in a group that defeats full-row justification', 'Movie quality selection and ratings must precede playback in the primary action row', ]) { @@ -844,14 +1510,14 @@ test('reports Discover media tabs, compact filters, button shadows, and scoped p 'Trending must retain its own five-choice shared media filter row', 'Trending media choices must render the corresponding complete discovery controls', 'every Trending media destination must place the shared media filters above its own Filters controls', - 'filter buttons must resolve through the shared 20-pixel height', + 'filter buttons must resolve through the shared action-height token', 'filter button focus emphasis must render outside the fixed control box', 'selected filter emphasis must render outside the fixed control box', 'pinned filter section icons must use the shared dark-blue selected surface', 'the title visibility filter must consume the shared compact filter button', 'non-filter buttons must retain the shared ratings-style black readability shadow', 'filter and sort buttons must remain exempt from the shared action-button shadow', - 'the legacy Request List sort-direction button must remain shadow-free', + 'Request List sort direction must retain the shared shadow-free filter owner, accessible help and direction callback inside its pinned sort panel', 'detail disclosure pins must use the authenticated category-scoped per-user settings endpoint', 'detail disclosure pin storage must be scoped by media category', 'each detail page must consume only its own persistent pin category', @@ -862,3 +1528,416 @@ test('reports Discover media tabs, compact filters, button shadows, and scoped p ); } }); + +test('compact Request geometry checker accepts the shared owner and rejects regressions', () => { + const cardFile = 'src/components/RequestCard/index.tsx'; + const cssFile = 'src/styles/globals.css'; + const reason = + 'loaded and loading compact Request cards must share CSS geometry without fixed loaded height'; + const card = repositoryFilesWith()[cardFile]; + const css = repositoryFilesWith()[cssFile]; + const fails = (overrides) => + validateCurrentBatchContract(repositoryFilesWith(overrides)).some((error) => + error.includes(reason) + ); + assert.equal(fails({}), false, 'current shared compact geometry passes'); + assert.equal( + fails({ + [cardFile]: card.replaceAll( + 'request-card-compact-layout', + 'request-card-retired-layout' + ), + }), + true, + 'lost shared geometry fails' + ); + assert.equal( + fails({ + [cssFile]: css + '\n.request-card-compact-layout { height: 7.5rem; }', + }), + true, + 'fixed loaded height fails' + ); + assert.equal( + fails({ + [cssFile]: css + '\n.request-card-placeholder { height: 9.5rem; }', + }), + true, + 'retired tall placeholder fails' + ); + assert.equal( + fails({ + [cardFile]: card.replaceAll( + 'request-card-compact-layout', + 'request-card-compact-layout w-72' + ), + }), + true, + 'duplicate utility owner fails' + ); +}); + +test('request dropdown portal stacking accepts native CSS and rejects lower or lost owners', () => { + const cssFile = 'src/styles/globals.css'; + const source = repositoryFilesWith()[cssFile]; + const reason = + 'request dropdown portals must sit above the z-60 modal backdrop'; + const fails = (css) => + validateCurrentBatchContract(repositoryFilesWith({ [cssFile]: css })).some( + (error) => error.includes(reason) + ); + assert.equal( + fails(source), + false, + 'native z-index 100 retains the approved portal stack' + ); + assert.equal( + fails( + source.replace( + /(\.request-listbox-menu\s*\{[\s\S]*?z-index:)\s*100;/, + '$1 50;' + ) + ), + true, + 'lower portal stack fails' + ); + assert.equal( + fails( + source.replace( + /(\.request-listbox-menu\s*\{[\s\S]*?)z-index:\s*100;/, + '$1' + ) + ), + true, + 'missing portal stack owner fails' + ); + assert.equal( + fails(source + '\n.request-listbox-menu { z-index: 50; }'), + true, + 'competing lower owner fails' + ); +}); + +test('native disclosure layout accepts reordered CSS and rejects lost or competing geometry', () => { + const cssFile = 'src/styles/globals.css'; + const source = repositoryFilesWith()[cssFile]; + const reason = + 'Cast, Crew, and Subject Tags must match the shared segmented action-height surface'; + const fails = (css) => + validateCurrentBatchContract(repositoryFilesWith({ [cssFile]: css })).some( + (error) => error.includes(reason) + ); + const alter = (modify) => { + const ast = require('postcss').parse(source); + ast.walkRules((rule) => { + if (rule.selectors.includes('.detail-disclosure-control')) modify(rule); + }); + return ast.toString(); + }; + assert.equal( + fails(source), + false, + 'native layout retains its shared action-height owner' + ); + assert.equal( + fails( + alter((rule) => { + const nodes = [...rule.nodes]; + rule.removeAll(); + for (const node of nodes.reverse()) rule.append(node); + }) + ), + false, + 'declaration ordering is not part of the native layout contract' + ); + for (const property of [ + 'display', + 'align-items', + 'overflow', + 'border-radius', + 'border-width', + 'border-style', + 'height', + 'min-height', + 'max-height', + ]) + assert.equal( + fails(alter((rule) => rule.walkDecls(property, (decl) => decl.remove()))), + true, + `lost ${property} owner fails` + ); + assert.equal( + fails(source + '\n.detail-disclosure-control { height: 20px; }'), + true, + 'competing fixed control height fails' + ); + assert.equal( + fails(source + '\n.detail-disclosure-control { border: 0; }'), + true, + 'competing border shorthand fails' + ); + assert.equal( + fails(source + '\n.detail-disclosure-control { border-radius: 0.5rem; }'), + true, + 'competing corner geometry fails' + ); +}); + +test('pinned filter ownership accepts section descriptors and rejects missing, duplicate or empty panels', () => { + const reason = + 'filter categories must retain distinct labeled pinned sections and rendered content'; + const consumers = [ + 'src/components/Discover/MediaDiscoveryControls.tsx', + 'src/components/Discover/DiscoverMusic/index.tsx', + 'src/components/Blocklist/index.tsx', + 'src/components/IssueList/index.tsx', + 'src/components/RequestList/index.tsx', + 'src/components/Search/index.tsx', + 'src/components/Discover/DiscoverBooks/index.tsx', + 'src/components/Requests/index.tsx', + ]; + const baseline = validateCurrentBatchContract(repositoryFilesWith()); + for (const fileName of consumers) { + const source = repositoryFilesWith()[fileName]; + assert.equal( + baseline.some( + (error) => error.startsWith(`${fileName}:`) && error.includes(reason) + ), + false, + fileName + ); + const changed = source.replaceAll( + 'PinnedFilterSection', + 'RetiredFilterSection' + ); + assert.notEqual(changed, source, 'current pinned role exists'); + assert.ok( + validateCurrentBatchContract( + repositoryFilesWith({ [fileName]: changed }) + ).some( + (error) => error.startsWith(`${fileName}:`) && error.includes(reason) + ), + `${fileName}: detached pinned owner fails` + ); + } + const fileName = consumers[0]; + const source = repositoryFilesWith()[fileName]; + const fails = (changed) => + validateCurrentBatchContract( + repositoryFilesWith({ [fileName]: changed }) + ).some( + (error) => error.startsWith(`${fileName}:`) && error.includes(reason) + ); + assert.equal( + fails(source.replace("section: 'sortBy'", "section: 'filters'")), + true, + 'duplicate sections fail' + ); + assert.equal( + fails(source.replace("section: 'filters'", "section: 'retiredFilters'")), + true, + 'missing required section fails' + ); + assert.equal( + fails( + source.replace( + 'children: ', + 'children: undefined' + ) + ), + true, + 'empty grouped panel fails' + ); + assert.equal( + fails(source.replace('mediaType={type}', '')), + true, + 'missing pin context fails' + ); + const standalone = 'src/components/Search/index.tsx'; + const ast = require('typescript').createSourceFile( + 'Search.tsx', + repositoryFilesWith()[standalone], + require('typescript').ScriptTarget.Latest, + true, + require('typescript').ScriptKind.TSX + ); + let bounds; + const findPanel = (node) => { + if ( + !bounds && + require('typescript').isJsxElement(node) && + node.openingElement.tagName.getText(ast) === 'PinnedFilterSection' + ) + bounds = [node.openingElement.end, node.closingElement.pos]; + require('typescript').forEachChild(node, findPanel); + }; + findPanel(ast); + assert.ok(bounds, 'standalone pinned panel exists'); + const text = repositoryFilesWith()[standalone]; + assert.ok( + validateCurrentBatchContract( + repositoryFilesWith({ + [standalone]: text.slice(0, bounds[0]) + text.slice(bounds[1]), + }) + ).some( + (error) => error.startsWith(`${standalone}:`) && error.includes(reason) + ), + 'empty standalone panel fails' + ); +}); + +test('native filter geometry accepts grouped and reordered CSS but rejects lost or competing owners', () => { + const fileName = 'src/styles/globals.css'; + const source = repositoryFilesWith()[fileName]; + const reason = + 'native filter geometry must retain shared segmented spacing, wrapping, narrow bounds and pinned panel boundaries'; + const fails = (css) => + validateCurrentBatchContract(repositoryFilesWith({ [fileName]: css })).some( + (error) => error.includes(reason) + ); + const alter = (selector, change) => { + const ast = require('postcss').parse(source); + ast.walkRules((rule) => { + if (rule.selectors.includes(selector)) change(rule); + }); + return ast.toString(); + }; + assert.equal(fails(source), false); + assert.equal( + fails( + alter('.app-filter-segment-focus', (rule) => { + rule.selector += ', .another-segment'; + const nodes = [...rule.nodes]; + rule.removeAll(); + for (const node of nodes.reverse()) rule.append(node); + }) + ), + false, + 'selector grouping and declaration order are not the contract' + ); + for (const [selector, property] of [ + ['.app-filter-segment-focus', 'padding-inline'], + ['.app-filter-segment-focus', 'column-gap'], + ['.app-filter-search-control', 'max-width'], + ['.app-filter-row', 'flex-wrap'], + ['.app-pinned-filter-section', 'margin-bottom'], + ['.app-pinned-filter-panel', 'margin-top'], + ]) + assert.equal( + fails( + alter(selector, (rule) => + rule.walkDecls(property, (decl) => decl.remove()) + ) + ), + true, + `lost ${selector} ${property} fails` + ); + for (const competing of [ + '.app-filter-segment-focus { padding-inline: 8px; }', + '.app-filter-segment-focus { padding: 0; }', + '.app-filter-segment-focus { @apply px-2; }', + '.app-filter-button > button { padding-inline: 8px; }', + '.app-filter-search-control { max-width: none; }', + '.app-filter-row { flex-wrap: nowrap; }', + '.app-pinned-filter-section { margin: 0; }', + ]) + assert.equal( + fails(`${source}\n${competing}`), + true, + `competing owner fails: ${competing}` + ); +}); + +test('music and legacy request filters retain rendered control order and current direction ownership', () => { + const musicFile = 'src/components/Discover/DiscoverMusic/index.tsx'; + const music = repositoryFilesWith()[musicFile]; + const musicReason = + 'Music filters must preserve their effective control order inside the shared wrapping pinned panel'; + const directionFile = 'src/components/RequestList/index.tsx'; + const direction = repositoryFilesWith()[directionFile]; + const directionReason = + 'Request List sort direction must retain the shared shadow-free filter owner, accessible help and direction callback inside its pinned sort panel'; + const fails = (fileName, source, reason) => + validateCurrentBatchContract( + repositoryFilesWith({ [fileName]: source }) + ).some((error) => error.includes(reason)); + assert.equal(fails(musicFile, music, musicReason), false); + assert.equal(fails(directionFile, direction, directionReason), false); + assert.equal( + fails( + musicFile, + music + .replace('', '') + .replace('', ''), + musicReason + ), + true, + 'moving artist after keyword fails' + ); + assert.equal( + fails( + musicFile, + music.replace( + 'className="app-filter-row"', + 'className="flex flex-wrap gap-2"' + ), + musicReason + ), + true, + 'utility-owned row fails' + ); + assert.equal( + fails( + musicFile, + music.replace('messages.releaseYear)}', 'messages.genres)}'), + musicReason + ), + true, + 'replacing year control with another genre control fails' + ); + assert.equal( + fails( + directionFile, + direction.replace( + 'getFilterToggleButtonClass(false)', + 'getFilterToggleButtonClass(true)' + ), + directionReason + ), + true, + 'untruthful active direction variant fails' + ); + assert.equal( + fails( + directionFile, + direction.replace( + /setCurrentSortDirection\(\s*currentSortDirection ===/, + 'retiredSortDirection(currentSortDirection ===' + ), + directionReason + ), + true, + 'lost direction callback fails' + ); + assert.equal( + fails( + directionFile, + direction.replaceAll('Tooltip', 'RetiredHelp'), + directionReason + ), + true, + 'lost direction help fails' + ); + assert.equal( + fails( + directionFile, + direction.replace( + 'className={getFilterToggleButtonClass(false)}', + 'className="app-control-shadow-exempt z-40 mr-2 rounded-l-none px-3"' + ), + directionReason + ), + true, + 'retired utility style is not the current filter owner' + ); +}); diff --git a/bin/check-current-batch-contract.js b/bin/check-current-batch-contract.js index cb16e367a..e5c9bbd95 100644 --- a/bin/check-current-batch-contract.js +++ b/bin/check-current-batch-contract.js @@ -33,6 +33,7 @@ const fileNames = [ 'bin/check-refreshed-ui-style-lib.js', 'bin/check-refreshed-ui-style-lib.test.mjs', 'bin/run-cypress-start.mjs', + 'bin/local-validation.mjs', 'scripts/check-container-security.test.mjs', 'seerr-api.yml', 'src/styles/globals.css', diff --git a/bin/check-refreshed-ui-style-lib.js b/bin/check-refreshed-ui-style-lib.js index 362e00569..15842d93e 100644 --- a/bin/check-refreshed-ui-style-lib.js +++ b/bin/check-refreshed-ui-style-lib.js @@ -63,7 +63,7 @@ const sharedStyleReferencePattern = const stylesheetDefines = (stylesheet, className) => { const escaped = className.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - return new RegExp(`\\.${escaped}(?=[\\s,{:.>])`).test(stylesheet); + return new RegExp(`\\.${escaped}(?=[\\s,{:.>]|\\[)`).test(stylesheet); }; const validateGlobalStylesheet = (fileName, source) => { diff --git a/bin/check-refreshed-ui-style-lib.test.mjs b/bin/check-refreshed-ui-style-lib.test.mjs index 9a1f67e1b..5262361d8 100644 --- a/bin/check-refreshed-ui-style-lib.test.mjs +++ b/bin/check-refreshed-ui-style-lib.test.mjs @@ -29,6 +29,41 @@ const validSharedStyles = ` .request-card-artwork-gradient { background: rgb(var(--theme-artwork-gradient-black)); } `; +test('attribute-qualified semantic class definitions remain valid shared style owners', () => { + for (const selector of [ + ".app-filter-panel[data-filter-layout='expanded']", + '.app-filter-panel[aria-expanded="true"]', + '.app-filter-panel[data-filter-layout]', + ]) { + const result = validateRefreshedUiStyleBoundaries({ + 'src/styles/globals.css': `${validSharedStyles}\n${selector} {}`, + 'src/components/Example/index.tsx': + '
', + }); + assert.deepEqual(result.errors, []); + } +}); + +test('lookalike class names and an attribute-only selector cannot satisfy a missing semantic class', () => { + for (const selector of [ + '.app-filter-panels[data-filter-layout]', + '.app-filter-panel-extra[data-filter-layout]', + '.app-filter-panel_extra[data-filter-layout]', + "[data-filter-layout='expanded']", + ]) { + const result = validateRefreshedUiStyleBoundaries({ + 'src/styles/globals.css': `${validSharedStyles}\n${selector} {}`, + 'src/components/Example/index.tsx': + '
', + }); + assert.equal(result.errors.length, 1); + assert.match( + result.errors[0], + /no global CSS definition \(app-filter-panel\)/ + ); + } +}); + test('accepts shared blue surfaces and semantic card text', () => { const result = validateRefreshedUiStyleBoundaries({ 'src/components/Example/index.tsx': ` diff --git a/bin/local-validation.mjs b/bin/local-validation.mjs new file mode 100644 index 000000000..b0fe73782 --- /dev/null +++ b/bin/local-validation.mjs @@ -0,0 +1,776 @@ +import { spawn } from 'node:child_process'; +import { + existsSync, + lstatSync, + mkdtempSync, + readFileSync, + readdirSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { + basename, + dirname, + isAbsolute, + join, + relative, + resolve, + sep, +} from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { stripVTControlCharacters } from 'node:util'; + +const roots = ['server', 'src', 'bin', 'scripts', 'deploy', 'packaging']; +const candidate = /\.(?:test|spec)\.(?:[cm]?[jt]s|[jt]sx)$/; +const prefix = 'seerrng-local-validation-'; +export const expectedPackageBindings = Object.freeze({ + test: 'node bin/run-local-validation.mjs --tests-only', + 'validate:development': 'node bin/run-local-validation.mjs', + build: 'pnpm validate:development && pnpm build:compile', + 'build:all': 'pnpm build', + 'build:compile': 'run-p build:next:compile build:server:compile', + 'build:server:compile': + 'tsc --project server/tsconfig.json && copyfiles -u 2 server/templates/**/*.{html,pug} dist/templates && copyfiles -u 2 "server/i18n/locale/*.json" dist/i18n && tsc-alias -p server/tsconfig.json && node scripts/replace-server-import-aliases.mjs dist', + 'build:next:compile': 'next build --webpack', + 'build:server': 'pnpm validate:development && pnpm build:server:compile', + 'build:next': 'pnpm validate:development && pnpm build:next:compile', + dev: 'pnpm validate:development && pnpm dev:server', + 'dev:checked': 'pnpm dev', + 'dev:server': + "nodemon -e ts,json,yml --watch server --watch seerr-api.yml --exec 'ts-node -r tsconfig-paths/register --files --project server/tsconfig.json server/index.ts'", + 'test:node': 'node server/test/index.mts', + 'test:vitest': 'vitest run', + 'test:tooling': 'node bin/run-tooling-tests.mjs', + 'test:ci': + 'vitest run --reporter=default --reporter=junit --outputFile.junit=report.xml', +}); +const slash = (path) => path.split(sep).join('/'); +const inside = (parent, child) => { + const path = relative(parent, child); + return ( + path !== '' && + !path.startsWith(`..${sep}`) && + path !== '..' && + !isAbsolute(path) + ); +}; + +export function loadTypeScript(root) { + return createRequire(join(root, 'package.json'))('typescript'); +} + +export function validatePackageBindings(packageJson) { + const scripts = packageJson.scripts || {}; + for (const [name, expected] of Object.entries(expectedPackageBindings)) { + if (scripts[name] !== expected) + throw new Error( + `Local validation binding drift: ${name} must be ${JSON.stringify(expected)}` + ); + } + if (Object.hasOwn(scripts, 'prebuild')) + throw new Error( + 'Remove duplicate prebuild lifecycle gate; public build explicitly runs validation' + ); + for (const name of [ + 'build:server:compile', + 'build:next:compile', + 'dev:server', + ]) { + if ( + typeof scripts[name] !== 'string' || + !scripts[name].trim() || + /validate:development|run-local-validation|pnpm (?:build|dev)(?:\s|$)/.test( + scripts[name] + ) + ) { + throw new Error( + `Missing or recursive internal compile/development command: ${name}` + ); + } + } +} + +export function validateGovernanceSources(agents, hook) { + for (const route of [ + 'docs/maintainers/ui-style-standard.md', + 'docs/maintainers/ui-fix-it.md', + 'docs/maintainers/ui-forward-merge-guide.md', + 'pnpm validate:development', + ]) { + if (!agents.includes(route)) + throw new Error( + `AGENTS.md is missing the required development route: ${route}` + ); + } + const commands = hook + .replace(/\r/g, '') + .split('\n') + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith('#')); + const required = [ + '[ -n "$HUSKY_BYPASS" ] || pnpm attribution:check || exit $?', + 'pnpm exec lint-staged || exit $?', + 'pnpm validate:development', + ]; + if (JSON.stringify(commands) !== JSON.stringify(required)) + throw new Error( + 'Commit hook must preserve attribution and lint-staged checks, then unconditionally run pnpm validate:development' + ); +} + +export function preflight( + root, + { + testsOnly = false, + inherited = process.env, + nodeVersion = process.version, + } = {} +) { + const require = createRequire(join(root, 'package.json')); + const packageJson = JSON.parse( + readFileSync(requireFile(root, 'package.json'), 'utf8') + ); + let semver; + try { + semver = require('semver'); + } catch { + throw new Error( + 'Missing installed semver dependency; restore the project dependencies before validation' + ); + } + if ( + !packageJson.engines?.node || + !semver.satisfies(nodeVersion, packageJson.engines.node) + ) + throw new Error( + `Node ${nodeVersion} does not satisfy package engines ${packageJson.engines?.node || '(missing)'}; select the supported installed runtime` + ); + const agent = inherited.npm_config_user_agent; + if (agent) { + const version = /(?:^|\s)pnpm\/([^\s]+)/.exec(agent)?.[1]; + if ( + !version || + !packageJson.engines?.pnpm || + !semver.satisfies(version, packageJson.engines.pnpm) + ) + throw new Error( + `Use pnpm satisfying package engines ${packageJson.engines?.pnpm || '(missing)'}, not ${agent.split(' ')[0]}` + ); + } + for (const name of [ + 'typescript', + 'vitest', + 'ts-node', + 'tsconfig-paths', + '@swc/core', + ...(testsOnly ? [] : ['eslint', 'next', 'prettier']), + ]) { + const wanted = + packageJson.dependencies?.[name] || packageJson.devDependencies?.[name]; + let installed; + try { + installed = require(`${name}/package.json`).version; + } catch { + throw new Error( + `Missing installed ${name}; restore project dependencies before validation (no automatic install)` + ); + } + if (!wanted || !semver.satisfies(installed, wanted)) + throw new Error( + `Installed ${name} ${installed} does not satisfy package ${wanted || '(missing declaration)'}` + ); + } + if (!testsOnly) { + for (const file of [ + 'AGENTS.md', + '.husky/pre-commit', + 'docs/maintainers/ui-style-standard.md', + 'docs/maintainers/ui-fix-it.md', + 'docs/maintainers/ui-forward-merge-guide.md', + ]) + requireFile(root, file); + validateGovernanceSources( + readFileSync(join(root, 'AGENTS.md'), 'utf8'), + readFileSync(join(root, '.husky/pre-commit'), 'utf8') + ); + validatePackageBindings(packageJson); + } +} + +export function frameworkOf(source, file, ts) { + const tree = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true); + const imports = new Set(); + const visit = (node) => { + if ( + (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && + node.moduleSpecifier && + ts.isStringLiteral(node.moduleSpecifier) + ) + imports.add(node.moduleSpecifier.text); + if ( + ts.isCallExpression(node) && + node.arguments.length === 1 && + ts.isStringLiteral(node.arguments[0]) && + (node.expression.kind === ts.SyntaxKind.ImportKeyword || + (ts.isIdentifier(node.expression) && + node.expression.text === 'require')) + ) + imports.add(node.arguments[0].text); + ts.forEachChild(node, visit); + }; + visit(tree); + const native = imports.has('node:test'); + const vitest = + [...imports].some((name) => /^vitest(?:\/|$)/.test(name)) || + /\.vitest\.test\.[cm]?tsx?$/.test(file); + if (native && vitest) throw new Error(`Mixed test frameworks: ${file}`); + if (!native && !vitest) throw new Error(`Unclassified test file: ${file}`); + return vitest ? 'vitest' : /\.[cm]?jsx?$/.test(file) ? 'node-js' : 'node-ts'; +} + +export function toolingOwnership(source, ts) { + const tree = ts.createSourceFile( + 'run-tooling-tests.mjs', + source, + ts.ScriptTarget.Latest, + true + ); + const arrays = new Map(); + let selection; + let invocation = false; + const visit = (node) => { + if ( + ts.isVariableDeclaration(node) && + ts.isIdentifier(node.name) && + node.name.text === 'tests' + ) { + selection = node.initializer + ?.getText(tree) + .replace(/\s/g, '') + .replace(/"/g, "'"); + } + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === 'spawnSync' + ) { + const argumentsText = node.arguments + .slice(0, 2) + .map((argument) => + argument.getText(tree).replace(/\s/g, '').replace(/"/g, "'") + ); + invocation ||= + argumentsText[0] === 'process.execPath' && + argumentsText[1] === "['--test',...tests]"; + } + if ( + ts.isVariableDeclaration(node) && + ts.isIdentifier(node.name) && + ['portableTests', 'posixOnlyTests'].includes(node.name.text) + ) { + if ( + !node.initializer || + !ts.isArrayLiteralExpression(node.initializer) || + node.initializer.elements.some((entry) => !ts.isStringLiteral(entry)) + ) { + throw new Error( + `Unsupported tooling ownership declaration: ${node.name.text}` + ); + } + if (arrays.has(node.name.text)) + throw new Error('Duplicate tooling ownership declaration'); + arrays.set( + node.name.text, + node.initializer.elements.map((entry) => entry.text) + ); + } + ts.forEachChild(node, visit); + }; + visit(tree); + if ( + arrays.size !== 2 || + [...arrays.values()].some((files) => files.length === 0) + ) + throw new Error('Missing tooling ownership inventory'); + const entries = [ + ...arrays.get('portableTests'), + ...arrays.get('posixOnlyTests'), + ]; + if (new Set(entries).size !== entries.length) + throw new Error('Duplicate tooling test ownership'); + if ( + selection !== + "process.platform==='win32'?portableTests:[...portableTests,...posixOnlyTests]" || + !invocation + ) + throw new Error( + 'Unsupported tooling execution selection; review ownership before running' + ); + return arrays; +} + +function requireFile(root, file) { + const absolute = resolve(root, file); + if ( + !inside(root, absolute) || + !existsSync(absolute) || + !lstatSync(absolute).isFile() || + lstatSync(absolute).size === 0 || + !inside(root, realpathSync(absolute)) + ) { + throw new Error(`Missing, empty or unsafe required file: ${file}`); + } + return absolute; +} + +export function discoverTests( + root, + { platform = process.platform, ts = loadTypeScript(root) } = {} +) { + root = realpathSync(root); + const files = []; + const visit = (directory) => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + if (['node_modules', '.git', 'dist', '.next'].includes(entry.name)) + continue; + const absolute = join(directory, entry.name); + if (entry.isSymbolicLink()) + throw new Error( + `Symlink in test discovery scope: ${slash(relative(root, absolute))}` + ); + if (entry.isDirectory()) visit(absolute); + else if (entry.isFile() && candidate.test(entry.name)) { + const file = slash(relative(root, absolute)); + const framework = frameworkOf(readFileSync(absolute, 'utf8'), file, ts); + files.push({ file, framework, owner: framework, selected: true }); + } + } + }; + for (const directory of roots) { + const absolute = join(root, directory); + if ( + !existsSync(absolute) || + lstatSync(absolute).isSymbolicLink() || + !lstatSync(absolute).isDirectory() + ) + throw new Error(`Missing or unsafe test scope: ${directory}`); + visit(absolute); + } + if (files.length === 0) throw new Error('No test files discovered'); + const runner = requireFile(root, 'bin/run-tooling-tests.mjs'); + const ownership = toolingOwnership(readFileSync(runner, 'utf8'), ts); + for (const [kind, owned] of ownership) { + for (const file of owned) { + const test = files.find((entry) => entry.file === file); + if (!test || test.framework !== 'node-js') + throw new Error(`Missing or incompatible tooling test: ${file}`); + test.owner = 'tooling'; + test.selected = platform !== 'win32' || kind === 'portableTests'; + if (!test.selected) + test.exclusion = + 'Existing POSIX-only tooling suite; mandatory on Linux'; + } + } + return files.sort((a, b) => a.file.localeCompare(b.file)); +} + +export function chunkArguments(files, maxCharacters = 20_000) { + const chunks = []; + let chunk = [], + size = 0; + for (const file of files) { + if (file.length + 3 > maxCharacters) + throw new Error(`Test path exceeds argument budget: ${file}`); + if (size + file.length + 3 > maxCharacters && chunk.length) { + chunks.push(chunk); + chunk = []; + size = 0; + } + chunk.push(file); + size += file.length + 3; + } + if (chunk.length) chunks.push(chunk); + return chunks; +} + +export function createPlan( + root, + { testsOnly = false, platform = process.platform, ts } = {} +) { + root = realpathSync(root); + const inventory = discoverTests(root, { + platform, + ts: ts || loadTypeScript(root), + }); + const steps = []; + const add = (name, args, kind = 'check') => + steps.push({ name, command: process.execPath, args, kind }); + const required = (file) => requireFile(root, file); + if (!testsOnly) { + for (const [name, file] of [ + ['Translations', 'bin/check-i18n.js'], + ['Current batch contract', 'bin/check-current-batch-contract.js'], + ['Shared visual standard', 'bin/check-refreshed-ui-style.js'], + ['Formatting', 'bin/run-prettier.mjs'], + ]) + add(name, [ + required(file), + ...(name === 'Formatting' ? ['--check'] : []), + ]); + add('Lint', [ + required('node_modules/eslint/bin/eslint.js'), + './server/**/*.{ts,tsx}', + './src/**/*.{ts,tsx}', + 'bin/local-validation.mjs', + 'bin/local-validation.test.mjs', + 'bin/run-local-validation.mjs', + ]); + const tsc = required('node_modules/typescript/bin/tsc'); + required('server/tsconfig.json'); + required('tsconfig.json'); + add('Server types', [tsc, '--project', 'server/tsconfig.json', '--noEmit']); + add('Client route types', [ + required('node_modules/next/dist/bin/next'), + 'typegen', + ]); + add('Client types', [tsc, '--noEmit']); + } + for (const owner of ['vitest', 'node-ts', 'node-js', 'tooling']) { + const files = inventory + .filter((entry) => entry.owner === owner && entry.selected) + .map((entry) => entry.file); + if (!files.length) + throw new Error(`Unexpected zero-test execution lane: ${owner}`); + if (owner === 'vitest') { + const configs = [ + 'vitest.config.mts', + 'vitest.config.ts', + 'vitest.config.mjs', + 'vitest.config.js', + ].filter((file) => existsSync(join(root, file))); + if (configs.length !== 1) + throw new Error('Expected exactly one supported Vitest configuration'); + add( + 'Vitest', + [ + required('node_modules/vitest/vitest.mjs'), + 'run', + '--config', + '', + '--reporter=default', + '--reporter=json', + '--outputFile.json=', + ], + owner + ); + steps.at(-1).files = files; + steps.at(-1).config = required(configs[0]); + } else if (owner === 'tooling') { + add( + 'Platform-aware tooling', + [required('bin/run-tooling-tests.mjs')], + owner + ); + steps.at(-1).files = files; + } else { + const chunks = chunkArguments(files); + for (let i = 0; i < chunks.length; i++) { + const args = + owner === 'node-ts' + ? [ + required('server/test/index.mts'), + '--test-reporter', + 'tap', + ...chunks[i], + ] + : [ + '--test', + '--test-concurrency=1', + '--test-reporter=tap', + ...chunks[i], + ]; + add( + `${owner === 'node-ts' ? 'Native TypeScript' : 'Node JavaScript'} ${i + 1}/${chunks.length}`, + args, + owner + ); + steps.at(-1).files = chunks[i]; + } + } + } + return { root, platform, testsOnly, inventory, steps }; +} + +export function isolatedEnvironment(directory, inherited = process.env) { + const env = { ...inherited }; + for (const key of Object.keys(env)) { + if ( + /^(?:DB_|DATABASE_|TS_NODE_|NODE_OPTIONS$|VITEST$|CONFIG_DIRECTORY$|CI$)/.test( + key + ) + ) + delete env[key]; + } + return { + ...env, + NODE_ENV: 'test', + CONFIG_DIRECTORY: directory, + ALLOW_NETWORK: 'false', + SEERR_TEST_FAIL_ON_NETWORK: 'true', + NEXT_TELEMETRY_DISABLED: '1', + }; +} + +export function removeOwnedTemporaryDirectory(directory, parent = tmpdir()) { + const absolute = resolve(directory), + temporaryParent = realpathSync(parent); + if ( + dirname(absolute) !== resolve(parent) || + !basename(absolute).startsWith(prefix) || + !inside(temporaryParent, realpathSync(absolute)) || + lstatSync(absolute).isSymbolicLink() + ) { + throw new Error(`Refusing unsafe temporary cleanup: ${directory}`); + } + rmSync(absolute, { recursive: true, force: true }); +} + +export function vitestConfigSource(config, root, files) { + return `import original from ${JSON.stringify(pathToFileURL(config).href)};\nexport default async (environment) => {\n const base = await (typeof original === 'function' ? original(environment) : original);\n if (!base || typeof base !== 'object' || Array.isArray(base)) throw new Error('Unsupported Vitest config');\n if (base.test?.projects?.length) throw new Error('Vitest projects need explicit ownership');\n return {...base, root: ${JSON.stringify(root)}, test: {...base.test, include: ${JSON.stringify(files)}, passWithNoTests: false}};\n};\n`; +} + +export function testCount(output) { + const plain = stripVTControlCharacters(output); + const tests = [...plain.matchAll(/(?:^|\n)(?:#|ℹ) tests (\d+)\b/g)]; + const passes = [...plain.matchAll(/(?:^|\n)(?:#|ℹ) pass (\d+)\b/g)]; + const failures = [...plain.matchAll(/(?:^|\n)(?:#|ℹ) fail (\d+)\b/g)]; + if (!tests.length || !passes.length || !failures.length) + throw new Error('Missing native test summary; refusing silent pass'); + return { + total: tests.reduce((sum, match) => sum + Number(match[1]), 0), + active: [...passes, ...failures].reduce( + (sum, match) => sum + Number(match[1]), + 0 + ), + }; +} + +export function runCommand(step, options) { + return new Promise((complete, reject) => { + let tail = ''; + let termination = Promise.resolve(); + let terminationError; + let forceTermination; + const child = spawn(step.command, step.args, { + cwd: options.root, + env: options.env, + stdio: ['ignore', 'pipe', 'pipe'], + shell: false, + windowsHide: true, + detached: process.platform !== 'win32', + }); + const capture = (target) => (data) => { + target.write(data); + tail = (tail + data.toString()).slice(-2_000_000); + }; + child.stdout.on('data', capture(options.stdout)); + child.stderr.on('data', capture(options.stderr)); + const interrupt = () => { + if (!child.pid) return; + if (process.platform === 'win32') { + termination = new Promise((finished) => { + const killer = spawn( + join( + process.env.SystemRoot || 'C:\\Windows', + 'System32', + 'taskkill.exe' + ), + ['/PID', String(child.pid), '/T', '/F'], + { shell: false, windowsHide: true, stdio: 'ignore' } + ); + killer.on('error', (error) => { + terminationError = error; + child.kill(); + finished(); + }); + killer.on('close', (status) => { + if (status !== 0) { + terminationError = new Error( + 'Unable to terminate the validation process tree' + ); + child.kill(); + } + finished(); + }); + }); + } else { + try { + process.kill(-child.pid, 'SIGTERM'); + forceTermination = setTimeout(() => { + try { + process.kill(-child.pid, 'SIGKILL'); + } catch (error) { + if (error.code !== 'ESRCH') terminationError = error; + } + }, 5000); + forceTermination.unref(); + } catch (error) { + if (error.code !== 'ESRCH') { + terminationError = error; + child.kill(); + } + } + } + }; + options.signal?.addEventListener('abort', interrupt, { once: true }); + if (options.signal?.aborted) interrupt(); + child.on('error', reject); + child.on('close', async (status, signal) => { + clearTimeout(forceTermination); + options.signal?.removeEventListener('abort', interrupt); + await termination; + if (terminationError) + reject( + Object.assign( + new Error( + `Validation interrupted; child cleanup uncertain: ${terminationError.message}` + ), + { preserveTemporary: true } + ) + ); + else if (options.signal?.aborted) + reject(new Error('Validation interrupted')); + else if (status !== 0) + reject( + Object.assign( + new Error(`${step.name} failed (${signal || status})`), + { exitCode: status || 1 } + ) + ); + else complete(tail); + }); + }); +} + +export async function executePlan( + plan, + { + stdout = process.stdout, + stderr = process.stderr, + inherited = process.env, + executor = runCommand, + signal, + } = {} +) { + const directory = mkdtempSync(join(tmpdir(), prefix)); + const env = isolatedEnvironment(directory, inherited); + const totals = new Map(); + let preserveTemporary = false; + try { + for (const original of plan.steps) { + if (signal?.aborted) throw new Error('Validation interrupted'); + const step = { ...original, args: [...original.args] }; + const report = join(directory, 'vitest-report.json'); + if (step.kind === 'vitest') { + const config = join(directory, 'vitest.config.mjs'); + writeFileSync( + config, + vitestConfigSource(step.config, plan.root, step.files), + { flag: 'wx' } + ); + step.args = step.args.map((arg) => + arg + .replace('', config) + .replace('', report) + ); + } + stdout.write(`\n[${step.name}]\n`); + const output = await executor(step, { + root: plan.root, + env, + stdout, + stderr, + signal, + }); + if (step.kind !== 'check') { + let count; + if (step.kind === 'vitest') { + const result = JSON.parse(readFileSync(report, 'utf8')); + count = { + total: result.numTotalTests, + active: result.numPassedTests + result.numFailedTests, + }; + if (!Number.isInteger(count.total) || !Number.isInteger(count.active)) + throw new Error('Invalid Vitest test summary'); + const actual = new Set( + (result.testResults || []).map((entry) => + slash(relative(plan.root, resolve(plan.root, entry.name))) + ) + ); + if ( + actual.size !== step.files.length || + step.files.some((file) => !actual.has(file)) + ) + throw new Error( + 'Vitest excluded or added files outside its discovered ownership; refusing partial success' + ); + } else count = testCount(output); + const previous = totals.get(step.kind) || { total: 0, active: 0 }; + totals.set(step.kind, { + total: previous.total + count.total, + active: previous.active + count.active, + }); + } + } + for (const [kind, count] of totals) { + if (count.total <= 0 || count.active <= 0) + throw new Error(`Unexpected zero active tests: ${kind}`); + } + if (!totals.size) throw new Error('No test lanes executed'); + return totals; + } catch (error) { + preserveTemporary = error.preserveTemporary === true; + if (preserveTemporary) + stderr.write( + `Temporary validation files retained because child cleanup is uncertain: ${directory}\n` + ); + throw error; + } finally { + if (!preserveTemporary) removeOwnedTemporaryDirectory(directory); + } +} + +export function printPlan( + plan, + output = process.stdout, + { details = true } = {} +) { + output.write( + `Local ${plan.testsOnly ? 'test' : 'validation'} gate: ${plan.root}\n` + ); + output.write( + 'Isolation: fresh temporary CONFIG_DIRECTORY, NODE_ENV=test; no dependency installation.\n' + ); + output.write( + 'Existing HTTP/HTTPS test guards reject external requests; this is not an OS network sandbox.\n' + ); + for (const owner of ['vitest', 'node-ts', 'node-js', 'tooling']) { + const owned = plan.inventory.filter((entry) => entry.owner === owner); + output.write( + `${owner}: ${owned.filter((entry) => entry.selected).length} files selected, ${owned.filter((entry) => !entry.selected).length} existing platform exclusions\n` + ); + } + if (details) { + for (const entry of plan.inventory) + output.write( + `${entry.selected ? 'RUN' : 'PLATFORM-SKIP'} ${entry.owner} ${entry.file}${entry.exclusion ? ` (${entry.exclusion})` : ''}\n` + ); + for (const step of plan.steps) + output.write( + `${step.name}: ${JSON.stringify([step.command, ...step.args])}\n` + ); + } +} diff --git a/bin/local-validation.test.mjs b/bin/local-validation.test.mjs new file mode 100644 index 000000000..8b59386a1 --- /dev/null +++ b/bin/local-validation.test.mjs @@ -0,0 +1,659 @@ +import assert from 'node:assert/strict'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { + chunkArguments, + createPlan, + discoverTests, + executePlan, + expectedPackageBindings, + frameworkOf, + isolatedEnvironment, + loadTypeScript, + preflight, + removeOwnedTemporaryDirectory, + runCommand, + testCount, + toolingOwnership, + validateGovernanceSources, + validatePackageBindings, + vitestConfigSource, +} from './local-validation.mjs'; + +const root = resolve(fileURLToPath(new URL('..', import.meta.url))); +const ts = loadTypeScript(root); +const sink = { write() {} }; +const fixture = () => { + const directory = mkdtempSync(join(tmpdir(), 'seerrng-validation-fixture-')); + const write = (path, contents = '// fixture') => { + mkdirSync(dirname(join(directory, path)), { recursive: true }); + writeFileSync(join(directory, path), contents); + }; + for (const scope of [ + 'server', + 'src', + 'bin', + 'scripts', + 'deploy', + 'packaging', + ]) + mkdirSync(join(directory, scope)); + write('package.json', '{}'); + write( + 'vitest.config.mts', + 'export default {plugins: [], test: {setupFiles: ["setup.ts"]}};' + ); + write('node_modules/vitest/vitest.mjs'); + write('server/test/index.mts'); + write( + 'server/native.test.ts', + 'import test from "node:test"; test("native", () => {});' + ); + write( + 'src/native.test.tsx', + 'import test from "node:test"; test("tsx", () => {});' + ); + write( + 'src/component.test.ts', + 'import {test} from "vitest"; test("vitest", () => {});' + ); + write( + 'src/style.test.mjs', + 'import test from "node:test"; test("style", () => {});' + ); + write( + 'scripts/portable.test.mjs', + 'import test from "node:test"; test("tool", () => {});' + ); + write( + 'deploy/posix.test.mjs', + 'import test from "node:test"; test("posix", () => {});' + ); + write( + 'bin/run-tooling-tests.mjs', + 'const portableTests = ["scripts/portable.test.mjs"]; const posixOnlyTests = ["deploy/posix.test.mjs"]; const tests = process.platform === "win32" ? portableTests : [...portableTests, ...posixOnlyTests]; spawnSync(process.execPath, ["--test", ...tests], {});' + ); + return { + directory, + write, + cleanup: () => rmSync(directory, { recursive: true, force: true }), + }; +}; + +test('AST classification ignores comments and text, accepts actual imports and rejects ambiguous suites', () => { + assert.equal( + frameworkOf( + '/* import {test} from "vitest" */ import test from "node:test";', + 'x.test.ts', + ts + ), + 'node-ts' + ); + assert.equal( + frameworkOf('const {test} = require("node:test");', 'x.test.cjs', ts), + 'node-js' + ); + assert.equal(frameworkOf('import("vitest");', 'x.test.ts', ts), 'vitest'); + assert.throws( + () => frameworkOf('import "vitest"; import "node:test";', 'x.test.ts', ts), + /Mixed/ + ); + assert.throws( + () => frameworkOf('const label = "node:test";', 'x.test.ts', ts), + /Unclassified/ + ); +}); + +test('discovery accounts for every file once, includes TSX and new Node tests, and preserves platform ownership', () => { + const f = fixture(); + try { + f.write( + 'bin/new.test.mjs', + 'import test from "node:test"; test("new", () => {});' + ); + const linux = discoverTests(f.directory, { platform: 'linux', ts }); + const windows = discoverTests(f.directory, { platform: 'win32', ts }); + assert.equal(linux.length, 7); + assert.equal(new Set(linux.map(({ file }) => file)).size, 7); + assert.equal(linux.filter(({ owner }) => owner === 'tooling').length, 2); + assert.equal(linux.filter(({ owner }) => owner === 'node-ts').length, 2); + assert.equal(windows.filter(({ selected }) => !selected).length, 1); + assert.match( + windows.find(({ file }) => file === 'deploy/posix.test.mjs').exclusion, + /POSIX/ + ); + assert.equal( + windows.find(({ file }) => file === 'bin/new.test.mjs').owner, + 'node-js' + ); + f.write('server/unknown.test.ts', 'export const value = 1;'); + assert.throws(() => discoverTests(f.directory, { ts }), /Unclassified/); + } finally { + f.cleanup(); + } +}); + +test('tooling declarations fail closed for missing, duplicate, or dynamic ownership', () => { + assert.throws( + () => toolingOwnership('const portableTests = [];', ts), + /Missing/ + ); + assert.throws( + () => + toolingOwnership( + 'const portableTests = ["a"]; const posixOnlyTests = ["a"];', + ts + ), + /Duplicate/ + ); + assert.throws( + () => + toolingOwnership( + 'const portableTests = find(); const posixOnlyTests = ["a"];', + ts + ), + /Unsupported/ + ); + const f = fixture(); + try { + rmSync(join(f.directory, 'scripts/portable.test.mjs')); + assert.throws( + () => discoverTests(f.directory, { ts }), + /Missing or incompatible tooling test/ + ); + } finally { + f.cleanup(); + } +}); + +test('plan is read-only, partitions framework runs and preserves the original Vitest configuration', () => { + const f = fixture(); + try { + const before = readFileSync(join(f.directory, 'vitest.config.mts'), 'utf8'); + const plan = createPlan(f.directory, { + testsOnly: true, + ts, + platform: 'linux', + }); + assert.equal(plan.steps.length, 4); + assert.deepEqual(plan.steps.find(({ kind }) => kind === 'node-ts').files, [ + 'server/native.test.ts', + 'src/native.test.tsx', + ]); + assert.deepEqual(plan.steps.find(({ kind }) => kind === 'vitest').files, [ + 'src/component.test.ts', + ]); + assert.equal( + plan.steps.find(({ kind }) => kind === 'tooling').args[0], + join(f.directory, 'bin/run-tooling-tests.mjs') + ); + const generated = vitestConfigSource( + join(f.directory, 'vitest.config.mts'), + f.directory, + ['src/component.test.ts'] + ); + assert.match(generated, /\.\.\.base/); + assert.match(generated, /\.\.\.base\.test/); + assert.match(generated, /passWithNoTests: false/); + assert.match(generated, /Vitest projects need explicit ownership/); + assert.equal( + readFileSync(join(f.directory, 'vitest.config.mts'), 'utf8'), + before + ); + assert.equal(existsSync(join(f.directory, 'config')), false); + rmSync(join(f.directory, 'src/component.test.ts')); + assert.throws( + () => createPlan(f.directory, { testsOnly: true, ts }), + /zero-test execution lane: vitest/ + ); + } finally { + f.cleanup(); + } +}); + +test('full validation retains original validators and type/format/lint checks without nested duplicate test commands', () => { + const f = fixture(); + try { + for (const path of [ + 'bin/check-i18n.js', + 'bin/check-current-batch-contract.js', + 'bin/check-refreshed-ui-style.js', + 'bin/run-prettier.mjs', + 'node_modules/eslint/bin/eslint.js', + 'node_modules/typescript/bin/tsc', + 'node_modules/next/dist/bin/next', + 'server/tsconfig.json', + 'tsconfig.json', + ]) + f.write(path); + const plan = createPlan(f.directory, { ts }); + assert.deepEqual( + plan.steps.filter(({ kind }) => kind === 'check').map(({ name }) => name), + [ + 'Translations', + 'Current batch contract', + 'Shared visual standard', + 'Formatting', + 'Lint', + 'Server types', + 'Client route types', + 'Client types', + ] + ); + assert.equal(plan.steps.filter(({ kind }) => kind !== 'check').length, 4); + rmSync(join(f.directory, 'bin/check-current-batch-contract.js')); + assert.throws(() => createPlan(f.directory, { ts }), /required file/); + } finally { + f.cleanup(); + } +}); + +test('standard package entries connect every test partition and explicit build/dev gates; shortcuts fail the contract', () => { + const valid = { + scripts: { ...expectedPackageBindings }, + }; + assert.doesNotThrow(() => validatePackageBindings(valid)); + for (const [name, changed] of [ + ['test', 'vitest run'], + ['validate:development', 'pnpm test'], + ['build', 'pnpm build:compile'], + ['build:all', 'run-p build:next:compile build:server:compile'], + ['dev', 'nodemon server/index.ts'], + ['test:ci', 'node bin/run-local-validation.mjs'], + ['build:server:compile', 'pnpm build'], + ]) { + assert.throws( + () => + validatePackageBindings({ + scripts: { ...valid.scripts, [name]: changed }, + }), + /binding drift|recursive/ + ); + } + assert.throws( + () => + validatePackageBindings({ + scripts: { ...valid.scripts, prebuild: 'pnpm validate:development' }, + }), + /duplicate prebuild/ + ); + assert.throws( + () => + validatePackageBindings({ + scripts: { ...valid.scripts, 'dev:server': '' }, + }), + /binding drift/ + ); +}); + +test('agent routes and unconditional commit validation are required; bypass wrappers cannot pass', () => { + const agents = + 'Read docs/maintainers/ui-style-standard.md docs/maintainers/ui-fix-it.md docs/maintainers/ui-forward-merge-guide.md and run pnpm validate:development'; + const hook = + '[ -n "$HUSKY_BYPASS" ] || pnpm attribution:check || exit $?\npnpm exec lint-staged || exit $?\npnpm validate:development\n'; + assert.doesNotThrow(() => validateGovernanceSources(agents, hook)); + for (const missing of [ + 'docs/maintainers/ui-style-standard.md', + 'docs/maintainers/ui-fix-it.md', + 'docs/maintainers/ui-forward-merge-guide.md', + 'pnpm validate:development', + ]) + assert.throws( + () => validateGovernanceSources(agents.replace(missing, ''), hook), + /missing the required/ + ); + for (const changed of [ + hook.replace( + 'pnpm validate:development', + '[ -n "$HUSKY_BYPASS" ] || pnpm validate:development' + ), + hook.replace( + 'pnpm validate:development', + 'pnpm validate:development || true' + ), + `exit 0\n${hook}`, + hook.replace('pnpm exec lint-staged || exit $?\n', ''), + ]) + assert.throws( + () => validateGovernanceSources(agents, changed), + /unconditionally/ + ); +}); + +test('runtime preflight rejects unsupported engines, dependency drift, and incomplete archive governance without installing anything', () => { + const f = fixture(); + try { + rmSync(join(f.directory, 'node_modules'), { recursive: true, force: true }); + symlinkSync( + join(root, 'node_modules'), + join(f.directory, 'node_modules'), + process.platform === 'win32' ? 'junction' : 'dir' + ); + const packageJson = JSON.parse( + readFileSync(join(root, 'package.json'), 'utf8') + ); + packageJson.scripts = { ...expectedPackageBindings }; + f.write('package.json', JSON.stringify(packageJson)); + const options = { + testsOnly: true, + nodeVersion: process.version, + inherited: { + npm_config_user_agent: `pnpm/10.24.0 npm/? node/${process.version}`, + }, + }; + assert.doesNotThrow(() => preflight(f.directory, options)); + assert.throws( + () => preflight(f.directory, { ...options, nodeVersion: 'v18.0.0' }), + /does not satisfy package engines/ + ); + assert.throws( + () => + preflight(f.directory, { + ...options, + inherited: { npm_config_user_agent: 'pnpm/9.0.0' }, + }), + /Use pnpm satisfying/ + ); + assert.throws( + () => + preflight(f.directory, { + ...options, + inherited: { npm_config_user_agent: 'npm/10.0.0' }, + }), + /Use pnpm satisfying/ + ); + assert.throws( + () => preflight(f.directory, { ...options, testsOnly: false }), + /required file: AGENTS.md/ + ); + f.write( + 'AGENTS.md', + 'docs/maintainers/ui-style-standard.md docs/maintainers/ui-fix-it.md docs/maintainers/ui-forward-merge-guide.md pnpm validate:development' + ); + f.write( + '.husky/pre-commit', + '[ -n "$HUSKY_BYPASS" ] || pnpm attribution:check || exit $?\npnpm exec lint-staged || exit $?\npnpm validate:development\n' + ); + for (const path of [ + 'docs/maintainers/ui-style-standard.md', + 'docs/maintainers/ui-fix-it.md', + 'docs/maintainers/ui-forward-merge-guide.md', + ]) + f.write(path, '# Required source document'); + assert.doesNotThrow(() => + preflight(f.directory, { ...options, testsOnly: false }) + ); + packageJson.devDependencies.typescript = '^999.0.0'; + f.write('package.json', JSON.stringify(packageJson)); + assert.throws( + () => preflight(f.directory, options), + /Installed typescript/ + ); + } finally { + f.cleanup(); + } +}); + +test('argument chunks retain every filename without shell composition or exceeding the Windows budget', () => { + const files = Array.from( + { length: 400 }, + (_, index) => `src/folder with spaces/item-${index}.test.ts` + ); + const chunks = chunkArguments(files, 500); + assert.deepEqual(chunks.flat(), files); + assert.ok( + chunks.every( + (chunk) => chunk.reduce((sum, file) => sum + file.length + 3, 0) <= 500 + ) + ); + assert.throws(() => chunkArguments(['x'.repeat(600)], 500), /budget/); +}); + +test('isolation removes inherited live configuration and escape flags without mutating the parent', () => { + const inherited = { + NODE_ENV: 'production', + CONFIG_DIRECTORY: '/live', + DB_HOST: 'live', + DB_PASS: 'secret', + DB_SSL_CA_FILE: '/secret', + DATABASE_URL: 'postgres://live', + NODE_OPTIONS: '--require injected', + TS_NODE_PROJECT: '/wrong', + VITEST: 'true', + CI: 'true', + ALLOW_NETWORK: 'true', + SEERR_TEST_FAIL_ON_NETWORK: 'false', + PATH: '/tools', + }; + const isolated = isolatedEnvironment('/owned', inherited); + assert.equal(isolated.NODE_ENV, 'test'); + assert.equal(isolated.CONFIG_DIRECTORY, '/owned'); + assert.equal(isolated.ALLOW_NETWORK, 'false'); + assert.equal(isolated.SEERR_TEST_FAIL_ON_NETWORK, 'true'); + assert.equal(isolated.DB_HOST, undefined); + assert.equal(isolated.NODE_OPTIONS, undefined); + assert.equal(isolated.CI, undefined); + assert.equal(isolated.PATH, '/tools'); + assert.equal(inherited.CONFIG_DIRECTORY, '/live'); +}); + +test('execution enforces positive active summaries and removes only its owned temporary directory on success or failure', async () => { + for (const mode of ['success', 'zero', 'missing', 'failure']) { + let directory; + const plan = { + root, + steps: [ + { + name: 'fixture', + command: process.execPath, + args: [], + kind: 'node-js', + }, + ], + }; + const executor = async (_, { env }) => { + directory = env.CONFIG_DIRECTORY; + assert.ok(existsSync(directory)); + assert.equal(env.NODE_ENV, 'test'); + if (mode === 'failure') + throw Object.assign(new Error('fixture failure'), { exitCode: 7 }); + if (mode === 'missing') return 'No summary'; + return mode === 'zero' + ? '# tests 2\n# pass 0\n# fail 0\n' + : '# tests 2\n# pass 2\n# fail 0\n'; + }; + if (mode === 'success') + assert.equal( + (await executePlan(plan, { executor, stdout: sink, stderr: sink })).get( + 'node-js' + ).active, + 2 + ); + else + await assert.rejects( + executePlan(plan, { executor, stdout: sink, stderr: sink }), + /zero active|Missing native|fixture failure/ + ); + assert.equal(existsSync(directory), false); + } + const unsafe = mkdtempSync(join(tmpdir(), 'other-owner-')); + try { + assert.throws( + () => removeOwnedTemporaryDirectory(unsafe), + /unsafe temporary cleanup/ + ); + assert.ok(existsSync(unsafe)); + assert.throws( + () => removeOwnedTemporaryDirectory(tmpdir()), + /unsafe temporary cleanup/ + ); + } finally { + rmSync(unsafe, { recursive: true, force: true }); + } +}); + +test('native subprocess failures propagate their actual exit status and zero-summary output cannot pass', async () => { + await assert.rejects( + runCommand( + { + name: 'failure', + command: process.execPath, + args: ['-e', 'process.exit(7)'], + }, + { root, env: isolatedEnvironment(tmpdir()), stdout: sink, stderr: sink } + ), + (error) => error.exitCode === 7 + ); + assert.deepEqual(testCount('ℹ tests 3\nℹ pass 3\nℹ fail 0\n'), { + total: 3, + active: 3, + }); + assert.throws(() => testCount('Done!'), /Missing native/); +}); + +test('CLI help succeeds without discovery and malformed options fail before running checks', async () => { + const command = { + name: 'help', + command: process.execPath, + args: [join(root, 'bin/run-local-validation.mjs'), '--help'], + }; + const options = { + root, + env: { ...process.env, NODE_OPTIONS: '' }, + stdout: sink, + stderr: sink, + }; + assert.match(await runCommand(command, options), /Usage:.*--tests-only/); + for (const args of [['--unknown'], ['--json']]) + await assert.rejects( + runCommand({ ...command, args: [command.args[0], ...args] }, options), + (error) => error.exitCode === 1 + ); +}); + +test('interruption cancels the owned child process tree and prevents later steps', async () => { + const controller = new AbortController(); + const handle = setTimeout(() => controller.abort(), 50); + try { + await assert.rejects( + runCommand( + { + name: 'interrupted', + command: process.execPath, + args: ['-e', 'setInterval(() => {}, 1000)'], + }, + { + root, + env: isolatedEnvironment(tmpdir()), + stdout: sink, + stderr: sink, + signal: controller.signal, + } + ), + /interrupted/ + ); + let called = false; + await assert.rejects( + executePlan( + { root, steps: [{ name: 'not started', args: [], kind: 'node-js' }] }, + { + signal: controller.signal, + stdout: sink, + stderr: sink, + executor: async () => { + called = true; + return ''; + }, + } + ), + /interrupted/ + ); + assert.equal(called, false); + } finally { + clearTimeout(handle); + } +}); + +test('Vitest must produce a valid report with active tests and cleanup still runs', async () => { + const f = fixture(); + try { + let directory; + const plan = { + root: f.directory, + steps: [ + { + name: 'Vitest fixture', + command: process.execPath, + args: [ + '', + '--outputFile.json=', + ], + kind: 'vitest', + config: join(f.directory, 'vitest.config.mts'), + files: ['src/component.test.ts'], + }, + ], + }; + const executor = async (step, { env }) => { + directory = env.CONFIG_DIRECTORY; + assert.match(readFileSync(step.args[0], 'utf8'), /component\.test\.ts/); + writeFileSync( + step.args[1].split('=')[1], + JSON.stringify({ + numTotalTests: 1, + numPassedTests: 1, + numFailedTests: 0, + testResults: [{ name: join(f.directory, 'src/component.test.ts') }], + }) + ); + return ''; + }; + assert.equal( + (await executePlan(plan, { executor, stdout: sink, stderr: sink })).get( + 'vitest' + ).total, + 1 + ); + assert.equal(existsSync(directory), false); + await assert.rejects( + executePlan(plan, { + executor: async () => '', + stdout: sink, + stderr: sink, + }), + /ENOENT/ + ); + await assert.rejects( + executePlan(plan, { + executor: async (step) => { + writeFileSync( + step.args[1].split('=')[1], + JSON.stringify({ + numTotalTests: 1, + numPassedTests: 1, + numFailedTests: 0, + testResults: [], + }) + ); + return ''; + }, + stdout: sink, + stderr: sink, + }), + /excluded or added files/ + ); + } finally { + f.cleanup(); + } +}); diff --git a/bin/run-local-validation.mjs b/bin/run-local-validation.mjs new file mode 100644 index 000000000..cf5b00c6f --- /dev/null +++ b/bin/run-local-validation.mjs @@ -0,0 +1,61 @@ +#!/usr/bin/env node +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + createPlan, + executePlan, + preflight, + printPlan, +} from './local-validation.mjs'; + +const args = process.argv.slice(2); +const allowed = new Set(['--help', '-h', '--plan', '--json', '--tests-only']); +if (args.some((arg) => !allowed.has(arg))) { + process.stderr.write('Unknown option. Use --help.\n'); + process.exitCode = 1; +} else if (args.includes('--help') || args.includes('-h')) { + process.stdout + .write(`Usage: node bin/run-local-validation.mjs [--tests-only] [--plan [--json]] + +Runs the existing local validators, formatting, lint, types, and discovered tests. +--tests-only Run all discovered test suites once, preserving their native runner. +--plan Print files, framework ownership, platform exclusions, and commands; + do not create files or launch children. +--json Machine-readable plan (requires --plan). +--help Show help without reading the project or creating files. + +Does not install dependencies, apply migrations, or edit GitHub workflows. +Native, Vitest-only, tooling and CI commands retain their existing behavior. +Failures and zero active tests fail closed.\n`); +} else { + const controller = new AbortController(); + const interrupt = () => controller.abort(); + try { + if (args.includes('--json') && !args.includes('--plan')) + throw new Error('--json requires --plan'); + const root = resolve(fileURLToPath(new URL('..', import.meta.url))); + preflight(root, { testsOnly: args.includes('--tests-only') }); + const plan = createPlan(root, { testsOnly: args.includes('--tests-only') }); + if (args.includes('--plan')) { + if (args.includes('--json')) + process.stdout.write(`${JSON.stringify(plan, null, 2)}\n`); + else printPlan(plan); + } else { + printPlan(plan, process.stdout, { details: false }); + process.on('SIGINT', interrupt); + process.on('SIGTERM', interrupt); + const totals = await executePlan(plan, { signal: controller.signal }); + for (const [lane, count] of totals) + process.stdout.write( + `${lane}: ${count.total} tests, ${count.active} active\n` + ); + process.stdout.write('\nLocal validation passed.\n'); + } + } catch (error) { + process.stderr.write(`${error.message}\n`); + process.exitCode = controller.signal.aborted ? 130 : error.exitCode || 1; + } finally { + process.off('SIGINT', interrupt); + process.off('SIGTERM', interrupt); + } +} diff --git a/cypress/e2e/discover.cy.ts b/cypress/e2e/discover.cy.ts index 454abf6d7..95f692984 100644 --- a/cypress/e2e/discover.cy.ts +++ b/cypress/e2e/discover.cy.ts @@ -1,6 +1,6 @@ const clickFirstTitleCardInSlider = (sliderTitle: string): void => { cy.contains('.slider-header', sliderTitle) - .next('[data-testid=media-slider]') + .closest('[data-testid=media-slider]') .find('[data-testid=title-card]', { timeout: 15000 }) .first() .trigger('mouseover') @@ -8,7 +8,7 @@ const clickFirstTitleCardInSlider = (sliderTitle: string): void => { .invoke('text') .then((text) => { cy.contains('.slider-header', sliderTitle) - .next('[data-testid=media-slider]') + .closest('[data-testid=media-slider]') .find('[data-testid=title-card]') .first() .click(); @@ -121,7 +121,7 @@ describe('Discover', () => { cy.visit('/'); cy.wait('@getMedia'); cy.contains('.slider-header', 'Recently Added') - .next('[data-testid=media-slider]') + .closest('[data-testid=media-slider]') .find('[data-testid=title-card]') .first() .find('[data-testid=title-card-title]') @@ -197,7 +197,7 @@ describe('Discover', () => { cy.visit('/'); cy.wait('@getRequests'); cy.contains('.slider-header', 'Recent Requests') - .next('[data-testid=media-slider]') + .closest('[data-testid=media-slider]') .scrollIntoView() .should('be.visible') .find('[data-testid=request-card]') @@ -322,14 +322,14 @@ describe('Discover', () => { cy.visit('/'); cy.wait('@getRequests'); cy.contains('.slider-header', 'Recent Requests') - .next('[data-testid=media-slider]') + .closest('[data-testid=media-slider]') .scrollIntoView() .should('be.visible') .contains('[data-testid=request-card]', 'Failed Card Book') .find('a[href="/book/OLCARDFAILEDW?manage=1&format=ebook"]') .should('contain', 'Failed'); cy.contains('.slider-header', 'Recent Requests') - .next('[data-testid=media-slider]') + .closest('[data-testid=media-slider]') .scrollIntoView() .should('be.visible') .contains('[data-testid=request-card]', 'Failed Card Album') diff --git a/cypress/e2e/library-discover-parity.cy.ts b/cypress/e2e/library-discover-parity.cy.ts index 25c5168b6..c7ca4c51b 100644 --- a/cypress/e2e/library-discover-parity.cy.ts +++ b/cypress/e2e/library-discover-parity.cy.ts @@ -209,6 +209,8 @@ describe('Books and Music discover parity', () => { cy.contains(/^Video$/).should('not.exist'); }); cy.contains('[data-testid=page-header]', 'Movies').should('be.visible'); + openFilterSection('Filters'); + openFilterSection('Sort By'); cy.contains('Filters').should('be.visible'); cy.contains('button', 'Clear Filters').should('be.visible'); cy.contains('button', 'Popularity').should('be.visible'); @@ -259,6 +261,8 @@ describe('Books and Music discover parity', () => { cy.visit('/discover/music'); cy.wait('@getMusic'); cy.contains('[data-testid=page-header]', 'Music').should('be.visible'); + openFilterSection('Filters'); + openFilterSection('Sort By'); cy.contains('button', 'Release Date').should('be.visible').click(); cy.contains('button', 'Release Date').click(); cy.location('search').should('include', 'sortBy=release_date.'); @@ -280,6 +284,8 @@ describe('Books and Music discover parity', () => { }).as('getMovies'); cy.visit('/discover/movies?genre=28'); cy.wait('@getMovies'); + openFilterSection('Filters'); + openFilterSection('Sort By'); cy.contains('button', 'Release Date').click(); cy.location('search').should('include', 'sortBy=release_date.desc'); cy.contains('button', 'Clear Filters').click(); @@ -294,6 +300,8 @@ describe('Books and Music discover parity', () => { }).as('getTv'); cy.visit('/discover/tv?status=Returning%20Series'); cy.wait('@getTv'); + openFilterSection('Filters'); + openFilterSection('Sort By'); cy.contains('button', 'First Air Date').click(); cy.location('search').should('include', 'sortBy=first_air_date.desc'); cy.contains('button', 'Clear Filters').click(); @@ -325,6 +333,7 @@ describe('Books and Music discover parity', () => { '/discover/music?primaryReleaseDateGte=2020-01-01&primaryReleaseDateLte=2020-12-31&sortBy=release_date.asc&genre=rock' ); cy.wait('@getMusic'); + openFilterSection('Filters'); cy.contains('button', 'Clear Filters').click(); cy.location('search').should('not.include', 'primaryReleaseDateGte='); cy.location('search').should('not.include', 'primaryReleaseDateLte='); @@ -1264,93 +1273,123 @@ describe('Books and Music discover parity', () => { cy.request('POST', '/api/v1/settings/main', { hideAvailable: false }); }); - it('honors hide blocklisted for books and music', () => { - cy.request('POST', '/api/v1/settings/main', { hideBlocklisted: true }); - cy.request('/api/v1/settings/public') - .its('body.hideBlocklisted') - .should('eq', true); - cy.intercept('GET', '/api/v1/settings/public').as('getPublicSettings'); - - cy.intercept('GET', '/api/v1/discover/books*', { - page: 1, - totalPages: 1, - totalResults: 2, - results: [ - { - id: 'OLBLOCKEDBOOKW', - mediaType: 'book', - title: 'Blocked Book', - author: 'Blocked Author', - firstPublishYear: 2026, - mediaInfo: { - status: 6, - requests: [], + [ + { manager: true, hideBlocklisted: true }, + { manager: true, hideBlocklisted: false }, + { manager: false, hideBlocklisted: true }, + { manager: false, hideBlocklisted: false }, + ].forEach(({ manager, hideBlocklisted }) => { + it(`preserves book and music blocklist visibility for ${manager ? 'managers' : 'ordinary users'} with hiding ${hideBlocklisted ? 'enabled' : 'disabled'}`, () => { + if (!manager) { + cy.loginAsUser(); + } + cy.request('/api/v1/auth/me') + .its('body.permissions') + .then((permissions) => { + const canManageBlocklist = + (BigInt(permissions) & BigInt(2 | 268435456)) !== BigInt(0); + expect(canManageBlocklist).to.eq(manager); + }); + // Mock only the displayed policy and optional-media availability; never + // change shared settings merely to exercise the visibility branches. + cy.intercept('GET', '**/api/v1/settings/public*', (req) => { + req.continue((response) => { + response.body = { + ...response.body, + hideBlocklisted, + booksEnabled: true, + ebookServiceEnabled: true, + audiobookServiceEnabled: true, + musicEnabled: true, + }; + }); + }).as('getPublicSettings'); + const shouldHide = !manager || hideBlocklisted; + + cy.intercept('GET', '/api/v1/discover/books*', { + page: 1, + totalPages: 1, + totalResults: 2, + results: [ + { + id: 'OLBLOCKEDBOOKW', + mediaType: 'book', + title: 'Blocked Book', + author: 'Blocked Author', + firstPublishYear: 2026, + mediaInfo: { + status: 6, + requests: [], + }, }, - }, - { - id: 'OLVISIBLEBOOKW', - mediaType: 'book', - title: 'Visible Book', - author: 'Visible Author', - firstPublishYear: 2026, - mediaInfo: { - status: 1, - requests: [], + { + id: 'OLVISIBLEBOOKW', + mediaType: 'book', + title: 'Visible Book', + author: 'Visible Author', + firstPublishYear: 2026, + mediaInfo: { + status: 1, + requests: [], + }, }, - }, - ], - }).as('getBlocklistedBooks'); + ], + }).as('getBlocklistedBooks'); - cy.visit('/discover/books'); - cy.wait('@getPublicSettings').then(({ response }) => { - expect(response?.statusCode).to.eq(200); - expect(response?.body.hideBlocklisted).to.eq(true); - }); - cy.reload(true); - cy.wait('@getPublicSettings'); - cy.wait('@getBlocklistedBooks'); - cy.contains('[data-testid=title-card-title]', 'Visible Book').should( - 'be.visible' - ); - cy.contains('Blocked Book').should('not.exist'); + cy.visit('/discover/books'); + cy.wait('@getPublicSettings').then(({ response }) => { + expect(response?.statusCode).to.eq(200); + expect(response?.body.hideBlocklisted).to.eq(hideBlocklisted); + }); + cy.reload(true); + cy.wait('@getPublicSettings'); + cy.wait('@getBlocklistedBooks'); + cy.contains('[data-testid=title-card-title]', 'Visible Book').should( + 'be.visible' + ); + cy.contains('[data-testid=title-card-title]', 'Blocked Book').should( + shouldHide ? 'not.exist' : 'be.visible' + ); - cy.intercept('GET', '/api/v1/discover/music*', { - page: 1, - totalPages: 1, - totalResults: 2, - results: [ - { - id: 'cccccccc-cccc-cccc-cccc-cccccccccccc', - mediaType: 'album', - title: 'Blocked Album', - 'primary-type': 'Album', - 'first-release-date': '2026-05-01', - 'artist-credit': [{ name: 'Blocked Artist' }], - mediaInfo: { - status: 6, + cy.intercept('GET', '/api/v1/discover/music*', { + page: 1, + totalPages: 1, + totalResults: 2, + results: [ + { + id: 'cccccccc-cccc-cccc-cccc-cccccccccccc', + mediaType: 'album', + title: 'Blocked Album', + 'primary-type': 'Album', + 'first-release-date': '2026-05-01', + 'artist-credit': [{ name: 'Blocked Artist' }], + mediaInfo: { + status: 6, + }, }, - }, - { - id: 'dddddddd-dddd-dddd-dddd-dddddddddddd', - mediaType: 'album', - title: 'Visible Album', - 'primary-type': 'Album', - 'first-release-date': '2026-05-01', - 'artist-credit': [{ name: 'Visible Artist' }], - mediaInfo: { - status: 1, + { + id: 'dddddddd-dddd-dddd-dddd-dddddddddddd', + mediaType: 'album', + title: 'Visible Album', + 'primary-type': 'Album', + 'first-release-date': '2026-05-01', + 'artist-credit': [{ name: 'Visible Artist' }], + mediaInfo: { + status: 1, + }, }, - }, - ], - }).as('getBlocklistedMusic'); + ], + }).as('getBlocklistedMusic'); - cy.visit('/discover/music'); - cy.wait('@getBlocklistedMusic'); - cy.contains('[data-testid=title-card-title]', 'Visible Album').should( - 'be.visible' - ); - cy.contains('Blocked Album').should('not.exist'); - cy.request('POST', '/api/v1/settings/main', { hideBlocklisted: false }); + cy.visit('/discover/music'); + cy.wait('@getBlocklistedMusic'); + cy.contains('[data-testid=title-card-title]', 'Visible Album').should( + 'be.visible' + ); + cy.contains('[data-testid=title-card-title]', 'Blocked Album').should( + shouldHide ? 'not.exist' : 'be.visible' + ); + }); }); it('keeps request list media filters addressable for book and music queues', () => { @@ -1372,14 +1411,18 @@ describe('Books and Music discover parity', () => { .its('request.url') .should('include', 'mediaType=book') .and('include', 'filter=pending'); - cy.get('select[name=mediaType]').should('have.value', 'book'); - cy.get('select[name=filter]').should('have.value', 'pending'); + openFilterSection('Media Filters'); + openFilterSection('Task Filters'); + cy.get('button[aria-label="Media Type"]').should('contain', 'Books'); + cy.get('button[aria-label="Status"]').should('contain', 'Pending'); - cy.get('select[name=mediaType]').select('music'); + cy.get('button[aria-label="Media Type"]').click(); + cy.contains('[role=option]', /^Music$/).click(); cy.wait('@getRequests') .its('request.url') .should('include', 'mediaType=music'); cy.location('search').should('include', 'mediaType=music'); + cy.get('button[aria-label="Media Type"]').should('contain', 'Music'); }); it('marks a missing dual-format book side as partial when its service ID is zero', () => { diff --git a/cypress/e2e/magazine-discover.cy.ts b/cypress/e2e/magazine-discover.cy.ts index fa6908740..5a5fb5b09 100644 --- a/cypress/e2e/magazine-discover.cy.ts +++ b/cypress/e2e/magazine-discover.cy.ts @@ -78,7 +78,7 @@ describe('Magazine discovery sources', () => { ); }); - it('offers tap-sized public catalog suggestions that start a search', () => { + it('offers shared-size public catalog suggestions that start a search', () => { const publicQueries: string[] = []; cy.intercept('GET', '/api/v1/discover/magazines*', (request) => { publicQueries.push(String(request.query.query ?? '')); @@ -92,7 +92,26 @@ describe('Magazine discovery sources', () => { cy.contains('button', 'National Geographic') .should('be.visible') .and(($button) => { - expect($button[0].getBoundingClientRect().height).to.be.at.least(44); + const button = $button[0]; + const document = button.ownerDocument; + const view = document.defaultView!; + const probe = document.createElement('div'); + probe.style.position = 'absolute'; + probe.style.visibility = 'hidden'; + probe.style.height = view + .getComputedStyle(button) + .getPropertyValue('--action-control-height'); + document.body.appendChild(probe); + const sharedHeight = parseFloat(view.getComputedStyle(probe).height); + probe.remove(); + expect( + sharedHeight, + 'configured shared action height' + ).to.be.greaterThan(0); + expect(button.getBoundingClientRect().height).to.be.closeTo( + sharedHeight, + 0.5 + ); }) .click(); diff --git a/cypress/e2e/providers/tvdb.cy.ts b/cypress/e2e/providers/tvdb.cy.ts index 39a5afa6c..5798d7707 100644 --- a/cypress/e2e/providers/tvdb.cy.ts +++ b/cypress/e2e/providers/tvdb.cy.ts @@ -20,10 +20,10 @@ describe('TVDB Integration', () => { animeMetadataProviderSelector: '[data-testid="anime-metadata-provider-selector"]', seasonSelector: '[data-testid="season-selector"]', - season1: 'Season 1', - season2: 'Season 2', - season3: 'Season 3', - episodeList: '[data-testid="episode-list"]', + season1: 'button[aria-label="Expand Season 01"]', + season2: 'button[aria-label="Expand Season 02"]', + season3: 'button[aria-label="Expand Season 03"]', + episodeList: '[data-tree-part="episodes"]', episode9: '9 - Hang Men', }; @@ -64,6 +64,57 @@ describe('TVDB Integration', () => { return cy.wait('@saveMetadata'); }; + const openMediaServer = () => { + cy.get('button[aria-controls="series-media-server-panel"]') + .should('be.visible') + .then(($button) => { + if ($button.attr('aria-expanded') !== 'true') cy.wrap($button).click(); + }); + cy.get('#series-media-server-panel').should('be.visible'); + cy.get('#series-media-server-panel [data-selection-tree]').should('exist'); + }; + + const recordClientSeasonSummary = (tvId: number, seasonNumber: number) => { + cy.intercept('GET', `/api/v1/tv/${tvId}`, (request) => { + request.continue((response) => { + Cypress.log({ + name: 'client season summary', + message: JSON.stringify({ + id: response.body.id, + season: response.body.seasons?.find( + (season: { seasonNumber: number }) => + season.seasonNumber === seasonNumber + ), + }), + }); + }); + }).as('seriesDetails'); + }; + + const verifySsrSeasonSummary = (tvId: number, seasonNumber: number) => { + // Initial full-page metadata is server-side; don't require a browser GET. + cy.get('script#__NEXT_DATA__') + .invoke('text') + .then((text) => { + const details = JSON.parse(text).props.pageProps.tv; + const season = details.seasons.find( + (item: { seasonNumber: number }) => item.seasonNumber === seasonNumber + ); + Cypress.log({ + name: 'SSR season summary', + message: JSON.stringify({ id: details.id, season }), + }); + expect(details.id, 'SSR canonical series TMDB ID').to.eq(tvId); + expect(season, `SSR advertises season ${seasonNumber}`).not.to.equal( + undefined + ); + expect( + season.episodeCount, + 'SSR season meets the current tree episodeCount > 0 eligibility filter' + ).to.be.greaterThan(0); + }); + }; + beforeEach(() => { // Perform login cy.loginAsAdmin(); @@ -104,55 +155,83 @@ describe('TVDB Integration', () => { }); it('should display "Tomorrow is Ours" show information with multiple seasons from TVDB', () => { + recordClientSeasonSummary(72879, 2); + cy.intercept('GET', '/api/v1/tv/72879/season/2').as('tomorrowSeason2'); // Navigate to the TV show cy.visit(ROUTES.tomorrowIsOursTvShow); + verifySsrSeasonSummary(72879, 2); + openMediaServer(); + cy.get(SELECTORS.season2).should('be.visible'); // Verify that multiple seasons are displayed (TMDB has only 1 season, TVDB has multiple) // cy.get(SELECTORS.seasonSelector).should('exist'); // Select Season 2 and verify it loads - cy.contains(SELECTORS.season2) + cy.wait('@tomorrowSeason2').its('response.statusCode').should('eq', 200); + cy.get(SELECTORS.season2) .should('be.visible') + .and('have.attr', 'aria-expanded', 'false') .scrollIntoView() .click(); // Verify that episodes are displayed for Season 2 - cy.get(SELECTORS.episodeList).within(() => { - cy.contains('Episode 1').should('be.visible'); - cy.contains('Episode 247').scrollIntoView().should('be.visible'); - }); + cy.get(`${SELECTORS.episodeList}[aria-label="Season 02 Episodes"]`) + .should('be.visible') + .within(() => { + cy.contains('[data-tree-part="number"]', /^01$/).should('be.visible'); + cy.contains('[data-tree-part="number"]', /^247$/) + .scrollIntoView() + .should('be.visible'); + }); }); it('Should display "Monster" show information correctly when not existing on TVDB', () => { + recordClientSeasonSummary(225634, 1); + cy.intercept('GET', '/api/v1/tv/225634/season/1').as('monsterSeason1'); // Navigate to the TV show cy.visit(ROUTES.monsterTvShow); + verifySsrSeasonSummary(225634, 1); + openMediaServer(); + cy.get(SELECTORS.season1).should('be.visible'); // Select Season 1 - cy.contains(SELECTORS.season1) + cy.wait('@monsterSeason1').its('response.statusCode').should('eq', 200); + cy.get(SELECTORS.season1) .should('be.visible') + .and('have.attr', 'aria-expanded', 'false') .scrollIntoView() .click(); // Verify specific episode exists - cy.get(SELECTORS.episodeList).within(() => { - cy.contains('Episode 9').should('exist'); - cy.contains('Hang Men').should('exist'); - }); + cy.get(`${SELECTORS.episodeList}[aria-label="Season 01 Episodes"]`) + .should('be.visible') + .contains('[data-tree-part="episode"]', 'Hang Men') + .within(() => { + cy.contains('[data-tree-part="number"]', /^09$/).should('exist'); + cy.contains('[data-tree-part="name"]', 'Hang Men').should('exist'); + }); }); it('should display "Dragon Ball Z Kai" show information with multiple only 2 seasons from TVDB', () => { + recordClientSeasonSummary(61709, 2); + cy.intercept('GET', '/api/v1/tv/61709/season/2').as('dragonSeason2'); // Navigate to the TV show cy.visit(ROUTES.dragonnBallZKaiAnime); - - // Intercept season 1 request - cy.intercept('/api/v1/tv/61709/season/1').as('season1'); + verifySsrSeasonSummary(61709, 2); + openMediaServer(); + cy.get(SELECTORS.season2).should('be.visible'); // Select Season 2 and verify it visible - cy.contains(SELECTORS.season2) + cy.wait('@dragonSeason2').its('response.statusCode').should('eq', 200); + cy.get(SELECTORS.season2) .should('be.visible') + .and('have.attr', 'aria-expanded', 'false') .scrollIntoView() .click(); // select season 3 and verify it not visible - cy.contains(SELECTORS.season3).should('not.exist'); + cy.get(SELECTORS.season3).should('not.exist'); + cy.get(`${SELECTORS.episodeList}[aria-label="Season 02 Episodes"]`).should( + 'be.visible' + ); }); }); diff --git a/cypress/e2e/request-fail-download.cy.ts b/cypress/e2e/request-fail-download.cy.ts index 9d3bf7bb0..33d06081d 100644 --- a/cypress/e2e/request-fail-download.cy.ts +++ b/cypress/e2e/request-fail-download.cy.ts @@ -283,8 +283,15 @@ describe('manual fail and search from request status', () => { cy.get('.app-tooltip') .contains('button', 'Fail this download and search again') .should('be.visible') + .and('have.class', 'app-button') + .and('have.class', 'button-sm') .and(($button) => { - expect($button[0].getBoundingClientRect().height).to.be.at.least(44); + const bounds = $button[0].getBoundingClientRect(); + // This legacy phone action retains its touch target, unlike compact controls. + expect(bounds.height).to.be.at.least(44); + expect(window.getComputedStyle($button[0]).fontSize).to.eq('12px'); + expect(bounds.left).to.be.at.least(0); + expect(bounds.right).to.be.at.most(Cypress.config('viewportWidth')); }) .click(); cy.get('.app-tooltip') @@ -299,10 +306,9 @@ describe('manual fail and search from request status', () => { cy.contains('The release was failed and a new search was started.') .should('be.visible') .and(($message) => { - expect($message.closest('.pointer-events-auto')).to.have.css( - 'opacity', - '1' - ); + const notification = $message.closest('.toast').children().first(); + expect(notification).to.have.css('opacity', '1'); + expect(notification).to.have.css('pointer-events', 'auto'); }); cy.contains('Someone Else’s Movie') .parents('[data-testid=request-card]') diff --git a/cypress/e2e/request-retry.cy.ts b/cypress/e2e/request-retry.cy.ts index 3d7cf27a3..1e1fc79f0 100644 --- a/cypress/e2e/request-retry.cy.ts +++ b/cypress/e2e/request-retry.cy.ts @@ -135,7 +135,7 @@ describe('failed request retry', () => { }); cy.wait('@getFailedRequests'); cy.contains('.slider-header', 'Recent Requests') - .next('[data-testid=media-slider]') + .closest('[data-testid=media-slider]') .scrollIntoView() .should('be.visible') .contains('[data-testid=request-card]', 'Owned Failed Audiobook'); @@ -144,11 +144,30 @@ describe('failed request retry', () => { .parents('[data-testid=request-card]') .scrollIntoView() .within(() => { - cy.contains('button', 'Search Again') + cy.contains('button', /^Retry$/) .should('be.visible') .and(($button) => { - expect($button[0].getBoundingClientRect().height).to.be.at.least( - 44 + const button = $button[0]; + const document = button.ownerDocument; + const view = document.defaultView!; + const probe = document.createElement('div'); + probe.style.position = 'absolute'; + probe.style.visibility = 'hidden'; + probe.style.height = view + .getComputedStyle(button) + .getPropertyValue('--action-control-height'); + document.body.appendChild(probe); + const sharedHeight = parseFloat( + view.getComputedStyle(probe).height + ); + probe.remove(); + expect( + sharedHeight, + 'configured shared action height' + ).to.be.greaterThan(0); + expect(button.getBoundingClientRect().height).to.be.closeTo( + sharedHeight, + 0.5 ); }) .click(); @@ -158,7 +177,7 @@ describe('failed request retry', () => { .should('include', '/api/v1/request/801/retry'); cy.contains('Someone Else’s Failed Audiobook') .parents('[data-testid=request-card]') - .should('not.contain', 'Search Again'); + .should('not.contain', 'Retry'); }); }); }); diff --git a/cypress/e2e/request-status.cy.ts b/cypress/e2e/request-status.cy.ts index 3eed97726..89a0080b1 100644 --- a/cypress/e2e/request-status.cy.ts +++ b/cypress/e2e/request-status.cy.ts @@ -1,3 +1,14 @@ +const openRequestFilterSection = (label: string) => { + cy.get(`section[aria-label="${label}"]`) + .find('button.detail-disclosure-button') + .then(($button) => { + if ($button.attr('aria-expanded') === 'false') cy.wrap($button).click(); + }); + cy.get( + `section[aria-label="${label}"] button.detail-disclosure-button` + ).should('have.attr', 'aria-expanded', 'true'); +}; + describe('Request Status', () => { beforeEach(() => { cy.loginAsAdmin(); @@ -5,6 +16,7 @@ describe('Request Status', () => { it('opens on all requests and lets users choose a history window', () => { cy.visit('/requests'); + openRequestFilterSection('Filters'); cy.get('button[aria-label="Time Period"]') .should('be.visible') @@ -18,7 +30,7 @@ describe('Request Status', () => { cy.contains('[role=option]', 'All time').click(); cy.location('search').should('not.contain', 'timeFrame='); - cy.contains('button', 'Media Filters').click(); + openRequestFilterSection('Media Filters'); cy.contains('button', 'Books').should('be.visible'); cy.contains('button', 'Audiobooks').click(); cy.location('search').should('contain', 'mediaType=audiobook'); diff --git a/cypress/e2e/requests.cy.ts b/cypress/e2e/requests.cy.ts index 856349094..98b93758e 100644 --- a/cypress/e2e/requests.cy.ts +++ b/cypress/e2e/requests.cy.ts @@ -1,3 +1,14 @@ +const openRequestFilterSection = (label: string) => { + cy.get(`section[aria-label="${label}"]`) + .find('button.detail-disclosure-button') + .then(($button) => { + if ($button.attr('aria-expanded') === 'false') cy.wrap($button).click(); + }); + cy.get( + `section[aria-label="${label}"] button.detail-disclosure-button` + ).should('have.attr', 'aria-expanded', 'true'); +}; + describe('Requests', () => { beforeEach(() => { cy.loginAsAdmin(); @@ -5,6 +16,7 @@ describe('Requests', () => { it('opens on all requests and lets users choose a history window', () => { cy.visit('/requests'); + openRequestFilterSection('Filters'); cy.get('button[aria-label="Time Period"]') .should('be.visible') @@ -18,7 +30,7 @@ describe('Requests', () => { cy.contains('[role=option]', 'All time').click(); cy.location('search').should('not.contain', 'timeFrame='); - cy.contains('button', 'Media Filters').click(); + openRequestFilterSection('Media Filters'); cy.contains('button', 'Books').should('be.visible'); cy.contains('button', 'Audiobooks').click(); cy.location('search').should('contain', 'mediaType=audiobook'); @@ -68,6 +80,7 @@ describe('Requests', () => { cy.wait('@softwareStatus') .its('request.url') .should('not.include', 'filter='); + openRequestFilterSection('Task Filters'); cy.get('[aria-label="Task Filters"]') .contains('button', 'No Release Found') .click(); @@ -75,14 +88,14 @@ describe('Requests', () => { cy.wait('@softwareStatus') .its('request.url') .should('include', 'filter=unavailable'); - cy.get('[aria-label="Software requests"]').should('not.exist'); + cy.get('[aria-label="Software Requests"]').should('not.exist'); cy.get('[aria-label="Task Filters"]').contains('button', 'Failed').click(); cy.location('search').should('include', 'filter=failed'); cy.wait('@softwareStatus') .its('request.url') .should('include', 'filter=failed'); - cy.get('[aria-label="Software requests"]').should( + cy.get('[aria-label="Software Requests"]').should( 'contain.text', 'Failed software request' ); @@ -145,10 +158,10 @@ describe('Requests', () => { cy.visit('/requests'); cy.wait('@softwareStatus'); - cy.get('[aria-label="Software requests"]') + cy.get('[aria-label="Software Requests"]') .contains('Cancelled software request') .should('be.visible'); - cy.get('[aria-label="Software requests"]') + cy.get('[aria-label="Software Requests"]') .contains('button', 'Clear cancelled request') .click(); cy.get('body').then(($body) => { @@ -159,9 +172,14 @@ describe('Requests', () => { } }); cy.get('[role="dialog"]') - .should('contain.text', 'Clear this cancelled request?') - .contains('button', 'Clear cancelled request') - .click(); + .should('be.visible') + .within(() => { + cy.get('[data-testid="modal-title"]').should( + 'have.text', + 'Clear This Cancelled Request?' + ); + cy.contains('button', 'Clear cancelled request').click(); + }); cy.then(() => { expect(clientErrors.join('\n')).not.to.include( 'Transition.Child is used but it is missing a parent' diff --git a/cypress/e2e/responsive-media-audit.cy.ts b/cypress/e2e/responsive-media-audit.cy.ts index 49e05a58f..94ace8dcd 100644 --- a/cypress/e2e/responsive-media-audit.cy.ts +++ b/cypress/e2e/responsive-media-audit.cy.ts @@ -80,8 +80,23 @@ describe('Narrow-window media audit', () => { it('keeps Request Status and its action controls within a phone viewport', () => { cy.visit('/requests'); - cy.contains('button', 'Media Filters').click(); + cy.get( + 'section[aria-label="Task Filters"] button.detail-disclosure-button' + ).then(($button) => { + if ($button.attr('aria-expanded') === 'false') cy.wrap($button).click(); + }); + cy.get( + 'section[aria-label="Task Filters"] button.detail-disclosure-button' + ).should('have.attr', 'aria-expanded', 'true'); cy.contains('button', 'Clear Filters').should('be.visible'); + cy.get( + 'section[aria-label="Media Filters"] button.detail-disclosure-button' + ).then(($button) => { + if ($button.attr('aria-expanded') === 'false') cy.wrap($button).click(); + }); + cy.get( + 'section[aria-label="Media Filters"] button.detail-disclosure-button' + ).should('have.attr', 'aria-expanded', 'true'); assertNoHorizontalOverflow(); }); }); diff --git a/cypress/e2e/settings/discover-customization.cy.ts b/cypress/e2e/settings/discover-customization.cy.ts index e5632852c..a7d19dcb9 100644 --- a/cypress/e2e/settings/discover-customization.cy.ts +++ b/cypress/e2e/settings/discover-customization.cy.ts @@ -117,7 +117,7 @@ describe('Discover Customization', () => { // Confirming we have some results cy.contains('.slider-header', sliderTitle) - .next('[data-testid=media-slider]') + .closest('[data-testid=media-slider]') .find('[data-testid=title-card]'); cy.get('[data-testid=create-discover-option-form]').submit(); @@ -155,7 +155,7 @@ describe('Discover Customization', () => { cy.visit('/'); cy.contains('.slider-header', sliderTitle) - .next('[data-testid=media-slider]') + .closest('[data-testid=media-slider]') .find('[data-testid=title-card]'); cy.get('[data-testid=discover-start-editing]').click(); diff --git a/cypress/e2e/tv-details.cy.ts b/cypress/e2e/tv-details.cy.ts index d3b8bbee1..f84aa132c 100644 --- a/cypress/e2e/tv-details.cy.ts +++ b/cypress/e2e/tv-details.cy.ts @@ -10,35 +10,84 @@ describe('TV Details', () => { ); }); - it('shows standard and 4K requests in one segmented control', () => { + it('opens one request screen and chooses HD or 4K inside it without submitting', () => { cy.loginAsAdmin(); + let submissions = 0; + // The disposable runtime has no Sonarr services. Supply read-only HD/4K + // destinations so this entry/quality test does not test missing setup. + const sonarrServers = [false, true].map((is4k, index) => ({ + id: index + 1, + name: is4k ? 'Test Sonarr 4K' : 'Test Sonarr HD', + is4k, + isDefault: true, + activeProfileId: 1, + activeDirectory: is4k ? '/tv-4k' : '/tv', + activeTags: [], + })); + cy.intercept('GET', '/api/v1/service/sonarr', sonarrServers).as( + 'sonarrDestinations' + ); + sonarrServers.forEach((server) => { + cy.intercept('GET', `/api/v1/service/sonarr/${server.id}`, { + server, + profiles: [{ id: 1, name: server.is4k ? 'Ultra HD' : 'HD' }], + rootFolders: [{ id: 1, path: server.activeDirectory }], + languageProfiles: [], + tags: [], + }); + }); + cy.intercept('POST', '/api/v1/request*', (request) => { + submissions += 1; + request.reply({ + statusCode: 500, + body: { error: 'Unexpected submission' }, + }); + }); cy.intercept('GET', '/api/v1/settings/public', (request) => { request.continue((response) => { response.body.series4kEnabled = true; }); }); + cy.intercept('GET', '/api/v1/tv/66732', (request) => { + request.continue((response) => { + // This entry test uses unrequested media, independently of saved requests. + response.body.mediaInfo = null; + }); + }); cy.visit('/tv/66732'); - cy.get('[data-testid=format-request-option-standard]') + cy.wait('@sonarrDestinations').its('response.statusCode').should('eq', 200); + + cy.get('[data-testid=format-request-option-standard]').should('not.exist'); + cy.contains('button', /^Request$/) .filter(':visible') - .last() - .then(($standardButton) => { - cy.get('[data-testid=format-request-option-4k]') - .filter(':visible') - .last() + .should('be.enabled') + .click(); + cy.get('[role="dialog"]') + .should('be.visible') + .within(() => { + cy.get('[role="group"][aria-label="Quality"]') + .contains('button', /^HD$/) .should('be.visible') - .then(($fourKButton) => { - expect( - $fourKButton[0].getBoundingClientRect().left - ).to.be.greaterThan( - $standardButton[0].getBoundingClientRect().left - ); - }); + .and('have.attr', 'aria-pressed', 'true'); + cy.get('[role="group"][aria-label="Quality"]') + .contains('button', /^4K$/) + .should('be.enabled') + .click(); + cy.get('[role="group"][aria-label="Quality"]') + .contains('button', /^4K$/) + .should('have.attr', 'aria-pressed', 'true'); + cy.get('[role="group"][aria-label="Quality"]') + .contains('button', /^HD$/) + .click() + .should('have.attr', 'aria-pressed', 'true'); }); - cy.get('[role="group"][aria-label="Quality"]') - .should('be.visible') - .contains('button', 'HD') - .should('be.visible'); + cy.then(() => + expect( + submissions, + 'screen entry and quality choices do not submit' + ).to.eq(0) + ); }); it('hides the playback quality selector when 4K is not configured', () => { @@ -56,16 +105,69 @@ describe('TV Details', () => { it('shows seasons and expands episodes', () => { cy.loginAsAdmin(); - // Try to load stranger things - cy.visit('/tv/66732'); - - // intercept request for season info + // A full visit obtains initial metadata through SSR, outside cy.intercept. + // Record any client revalidation too, without assuming it will occur. + cy.intercept('GET', '/api/v1/tv/66732', (request) => { + request.continue((response) => { + Cypress.log({ + name: 'client season summary', + message: JSON.stringify({ + id: response.body.id, + season: response.body.seasons?.find( + (season: { seasonNumber: number }) => season.seasonNumber === 4 + ), + }), + }); + }); + }).as('seriesDetails'); + // The current tree prefetches season metadata before disclosure opens. cy.intercept('/api/v1/tv/66732/season/4').as('season4'); + cy.visit('/tv/66732'); + cy.get('script#__NEXT_DATA__') + .invoke('text') + .then((text) => { + const details = JSON.parse(text).props.pageProps.tv; + const season = details.seasons.find( + (item: { seasonNumber: number }) => item.seasonNumber === 4 + ); + Cypress.log({ + name: 'SSR season summary', + message: JSON.stringify({ id: details.id, season }), + }); + expect(details.id, 'SSR canonical Stranger Things TMDB ID').to.eq( + 66732 + ); + expect(season, 'SSR advertises season 4').not.to.equal(undefined); + expect( + season.episodeCount, + 'SSR season 4 meets the current tree episodeCount > 0 eligibility filter' + ).to.be.greaterThan(0); + }); + cy.get('button[aria-controls="series-media-server-panel"]') + .should('be.visible') + .then(($button) => { + if ($button.attr('aria-expanded') !== 'true') cy.wrap($button).click(); + }); + cy.get('#series-media-server-panel').should('be.visible'); + cy.get('#series-media-server-panel [data-selection-tree]').should('exist'); + cy.get('button[aria-label="Expand Season 04"]').should('be.visible'); + cy.wait('@season4').its('response.statusCode').should('eq', 200); - cy.contains('Season 4').should('be.visible').scrollIntoView().click(); - - cy.wait('@season4'); + cy.get('button[aria-label="Expand Season 04"]') + .should('be.visible') + .and('have.attr', 'aria-expanded', 'false') + .scrollIntoView() + .click(); + cy.get('button[aria-label="Collapse Season 04"]').should( + 'have.attr', + 'aria-expanded', + 'true' + ); - cy.contains('Chapter Nine').should('be.visible'); + cy.get('[data-tree-part="episodes"][aria-label="Season 04 Episodes"]') + .should('be.visible') + .contains('[data-tree-part="name"]', 'Chapter Nine') + .scrollIntoView() + .should('be.visible'); }); }); diff --git a/cypress/e2e/user/profile.cy.ts b/cypress/e2e/user/profile.cy.ts index f7a4c05e2..2c81a10e3 100644 --- a/cypress/e2e/user/profile.cy.ts +++ b/cypress/e2e/user/profile.cy.ts @@ -37,16 +37,44 @@ describe('User Profile', () => { cy.wait('@getWatchlist'); - const sliderHeader = cy.contains('.slider-header', 'Watchlist'); - - sliderHeader.scrollIntoView(); + // The supplied watchlist title renders a fallback card before metadata. + // Its parent div, not the outer slider item, is TmdbTitleCard's IO target. + cy.contains('.slider-header', 'Watchlist') + .closest('[data-testid=media-slider]') + .find('[data-testid=title-card]') + .first() + .parent() + .scrollIntoView() + .should('be.visible') + .should(($observedCard) => { + const target = $observedCard[0]; + const bounds = target.getBoundingClientRect(); + const window = target.ownerDocument.defaultView; + if (!window) throw new Error('Watchlist card has no browsing context'); + expect(bounds.width, 'observed card width').to.be.greaterThan(0); + expect(bounds.height, 'observed card height').to.be.greaterThan(0); + expect( + bounds.right, + 'observed card crosses viewport left edge' + ).to.be.greaterThan(0); + expect( + bounds.bottom, + 'observed card crosses viewport top edge' + ).to.be.greaterThan(0); + expect( + bounds.left, + 'observed card crosses viewport right edge' + ).to.be.lessThan(window.innerWidth); + expect( + bounds.top, + 'observed card crosses viewport bottom edge' + ).to.be.lessThan(window.innerHeight); + }); - cy.wait('@getTmdbMovie'); - // Wait a little longer to make sure the movie component reloaded - cy.wait(500); + cy.wait('@getTmdbMovie').its('response.statusCode').should('eq', 200); - sliderHeader - .next('[data-testid=media-slider]') + cy.contains('.slider-header', 'Watchlist') + .closest('[data-testid=media-slider]') .find('[data-testid=title-card]') .first() .trigger('mouseover') @@ -54,7 +82,7 @@ describe('User Profile', () => { .invoke('text') .then((text) => { cy.contains('.slider-header', 'Watchlist') - .next('[data-testid=media-slider]') + .closest('[data-testid=media-slider]') .find('[data-testid=title-card]') .first() .click(); diff --git a/cypress/e2e/user/request-folder-defaults.cy.ts b/cypress/e2e/user/request-folder-defaults.cy.ts index e975fbb63..d75ecc9f7 100644 --- a/cypress/e2e/user/request-folder-defaults.cy.ts +++ b/cypress/e2e/user/request-folder-defaults.cy.ts @@ -127,7 +127,8 @@ describe('Per-user request destination folders', () => { }); cy.get('[role="dialog"]') .contains('button', '/media/Movies/David') - .should('have.class', 'bg-indigo-500/20'); + .should('have.attr', 'data-table-part', 'choice-row') + .and('have.attr', 'aria-pressed', 'true'); }); it('shows the service default when a saved folder is no longer available', () => { @@ -186,6 +187,7 @@ describe('Per-user request destination folders', () => { cy.wait('@getRequestRootFolders'); cy.get('[role="dialog"]') .contains('button', '/media/Movies') - .should('have.class', 'bg-indigo-500/20'); + .should('have.attr', 'data-table-part', 'choice-row') + .and('have.attr', 'aria-pressed', 'true'); }); }); diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts index a95e0f315..c904c3888 100644 --- a/cypress/support/e2e.ts +++ b/cypress/support/e2e.ts @@ -1,5 +1,24 @@ import './commands'; +beforeEach(() => { + // These tests explicitly assert metadata HTTP responses. A browser-cached + // response bypasses cy.intercept even when the real UI loads successfully. + // Keep real payloads, requests and assertions; disable only test-side caching + // of read-only movie/series details and season metadata, before any visit. + cy.intercept( + { + method: 'GET', + pathname: /^\/api\/v1\/(?:movie|tv)\/\d+(?:\/season\/\d+)?$/, + middleware: true, + }, + (request) => { + request.on('before:response', (response) => { + response.headers['cache-control'] = 'no-store'; + }); + } + ); +}); + before(() => { if (Cypress.expose('SEED_DATABASE') === true) { cy.task('seedDatabase'); diff --git a/docs/maintainers/interface-integration-checkpoint.md b/docs/maintainers/interface-integration-checkpoint.md new file mode 100644 index 000000000..e855ede62 --- /dev/null +++ b/docs/maintainers/interface-integration-checkpoint.md @@ -0,0 +1,301 @@ +# Interface Integration Checkpoint + +This is the living evidence appendix for the forward-merge guide, not a release +approval or a complete site-audit claim. Read `AGENTS.md`, UI Style Standard and +UI Fix-It first. Preserve earlier accepted work while reviewing small batches. +Never infer human acceptance from test success or a journal's implementation note. + +## Preservation and coverage map + +| Work to preserve | Established owners and existing checks | Review boundary | +| ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Request Status and request-modal layout, filters, actions, title roles | `globals.css`, Request components; `src/styles/buttonGeometry.test.mjs`, `requestLayout.test.mjs`, current-batch contract | Earlier work is in scope for the contribution; a Series-first batch does not remove it. Final accepted references and current narrow renders remain to be assembled. | +| Shared page titles/headings, typography and spacing | `page-title`, `page-heading`, card/table families; `buttonGeometry.test.mjs`, current-batch contract | Preserve role owners and existing acceptance, not retired experimental values. | +| Aggregate loading/searching/mutation status | `page-title-row`, `page-status`, shared status/spinner; `buttonGeometry.test.mjs` and component tests | Keep one real activity indicator; completion/unmount must clear contributions. | +| Poster dimensions, buttons, media slots and hover | `poster-layout`, poster frame/region/control roles; `buttonGeometry.test.mjs`, `iconOnlyButtons.test.mjs` | Fixed browsing geometry is distinct from detail/credit posters. Trace all affected renderers. | +| Series tree, quality, native controls and disclosure ordering | Series components, shared selection/tree/ratings/disclosure owners; `seriesDetailsStyle.test.mjs`, native-action suites and reorder suites | Selection and permissions remain functional contracts. Mocked drag tests do not prove physical drag/touch acceptance. | +| Current Plex Collection repair | `server/api/plexapi.ts`, native collection routes/client; `server/api/plexCollections.test.ts`, `src/styles/mediaServerCollectionsClient.test.mjs`, `mediaServerCollections.test.mjs`, `plexCollections.test.mjs` | Optional smart marker is accepted only with remaining exact identity checks. Remove only membership, never the collection. | +| Mutable Watchlist/Collection state | `server/middleware/apiResponseCache.ts`, native clients, `ServiceWorkerSetup/sw.ts`; cache/client/service-worker tests | Fresh initial/pre-write reads and confirmed desired state; local Seerr watchlist and Plex Watchlist remain distinct. | +| Dropdown opacity, overflow and ancestor framing | Shared dropdown/menu surface and detail-card open state; `seriesDetailsStyle.test.mjs`, `Common/Dropdown/playbackStyle.test.tsx` | Parsed source ownership is not rendered paint-order proof. Compare open and closed desktop/narrow states. | + +The broader [Interface Preservation Inventory](interface-preservation-inventory.md) +has been reconciled against the journal and original chat. It covers the Request +page and shared roles, poster/browse prototypes, dropdown/mosaic cleanup, final +tree/table/workspace, ratings, ordering and native actions. It distinguishes +explicit acceptance, pending review, retired trials and opt-in references. +Neither document replaces the final three-way changed-file inventory. Rolled-back +successor changes must not be resurrected because an older log mentions them. + +## Confirmed provider evidence before this batch + +- Plex Collections: authorized Seerr Add, Remove and Add again with matching + provider and Seerr membership readback. The test collection was retained for + John's manual trial, not deleted. This was a bounded own-account verification. +- Plex Watchlist: the stale mismatch was reproduced; authorized removal of the + reported series reduced membership by one and a fresh Plex reload confirmed + absence. Adding it back restored membership and the Remove action. The item + remains saved. This is live evidence, separate from mocks. +- Jellyfin/Emby native actions: mocked tests only; no live write pass claimed. +- Previous focused passes do not establish a full cumulative suite/build pass. + Human visual acceptance and physical drag/touch review remain pending. + +## Current bounded batch + +Approved: improve portable instructions, regression checks and merge evidence; +repair only clear existing visual-rule violations in the Series/shared controls. +Not approved: broad backend/security repair, new design presets, NAS deployment, +GitHub push/PR or production-account tests as part of a visual audit. + +The original public `dev`, `build`, validation and hook bindings remain intact. +Focused checks support iterative previews; finalization requires one complete +gate on the exact candidate. `pnpm build` runs validation then compilation, so +do not run another identical full validation immediately beforehand. + +Known remaining visual debt: legacy Series/shared `@apply` and competing card +padding owners require a later bounded consumer audit. A long action-menu list +needs an approved bounded-viewport preset; do not invent dimensions here. This +batch does not establish that the page or application is Tailwind-free. + +Current isolated focused batch: **117 tests passed**, zero failures, skips, +cancellations or todos. Suites: local-validation bindings/discovery/isolation, +buttonGeometry, iconOnlyButtons, requestLayout, Series style and saved-item +client. New negative fixtures reject competing clipping/surface/gap owners and +retired icon geometry; Watchlist Remove tests confirm explicit desired false +and fresh already-completed state. Two old icon assertions were superseded by +the documented shared-padding/native poster-role contract, not waived. + +Isolation: separate candidate source, disposable `/tmp` config, Docker network +mode `none`, dependencies/store mounted read-only; no live config/DB mount. +Formatting and discovery plan passed. All 12 explicit files matched the mounted +source readback. Desktop/narrow menu inspection confirmed the shared black surface, +open content above its parent frame, keyboard access to the last option, and Escape +restoring normal clipping/layers/focus. Narrow tree headings still crowd; physical +drag/touch and final human visual acceptance remain pending. + +The one full `pnpm build` attempt passed translations, current-batch and shared- +style validators, formatting, lint, server/client types and all 85 Vitest files +(395 tests). **Blocked:** during the native TypeScript lane, the network guard +failed after AvailabilitySync because of an unstubbed TMDB attempt. The related +test, implementation and guard match the pre-batch source. This is an unchanged +test-isolation failure, not proof of a production backend defect. + +The isolated run was stopped after that required failure. Native TS is incomplete; +the cumulative native JS/tooling lanes and production compilation were not run. +No complete gate/build pass, exception or waived failure is claimed. No unrelated +backend/test repair was made in that attempt. Preserve the failed receipt +separately from focused passing evidence. + +John subsequently authorized repairing that suite's test isolation with production +backend code untouched. The focused failure was reproduced with the guard enabled; +the test now mocks the actual retained TMDB client with typed season fixtures and +per-test restoration. Unexpected fixture IDs fail outside the production catch. +A new unavailable-enrichment case checks the exact lookup and preserved status. +All 18 focused tests pass, with no skips/cancellations/todos. This test-only repair +is not a production backend fix or a complete cumulative gate/build pass. The +final gate and human acceptance remain pending; unrelated failures still require +scoped direction before further repair. + +The visual/client fixtures were also tightened: negative CSS/role fixtures must +fail with their intended diagnostics, wrong icon padding is checked without a +duplicate declaration, and Watchlist Remove rejects a valid response that still +reports saved membership. Recovery remains read-only. The six focused visual/ +client/runner suites now pass **119 tests**, zero failures/skips/cancellations/ +todos; the separate AvailabilitySync focused suite passes 18. Production backend, +network guard, package/hook bindings and lockfile remain unchanged. The complete +candidate is being verified separately; these focused receipts are not a waiver +or full-gate claim. Internal-only test hardening adds no user-facing feature. + +## Subsequent isolated verification and input blockers + +The repaired cumulative retry passed the preliminary validators, formatting, +lint, server/client types and all85 Vitest files/395 tests. AvailabilitySync +passed within the native TS lane. A different required after-hook then failed +following the Plex scanner suites: the guard caught an attempted request to a +fake Plex test host. That scanner source and guard are unchanged from the +preserved baseline. This is isolation evidence, not a production bug diagnosis. +The long run was stopped there; exit137 records the stop, separately from the +hook failure. Native TS remains partial; no production compilation was reached. +No scanner/guard repair or failure waiver was made. + +The previously unrun native JavaScript lane was executed separately on the same +frozen candidate: all46 selected files ran,391 tests,357 passed,34 failed, zero +skips/todos/cancellations. This is a failing partial receipt, not a complete gate. +Failures include superseded source/utility assertions and unresolved contracts; +replace only clearly superseded assertions with equivalent role/behavior tests +and negative fixtures. Do not restore retired layouts or normalize uncertain +palette/ordering decisions merely to increase the pass count. + +Full tooling inputs are also unavailable: the mounted app source contains only +the CI workflow, while tests read other chart/release/preview workflows and root +release fixtures. The bare Node/Alpine image lacks required native tools. +The earlier snapshot additionally omitted an existing Unraid template fixture; +that omission is corrected in future manifests, not inserted into the frozen +receipt. Establish complete authoritative pinned Git inputs and a suitable +disposable toolchain before another final run. Plan discovery is not proof of +fixture/tool availability. No borrowed fixtures, installed live tools or suite +exclusions convert these blockers into a pass. + +## Bounded visual-contract follow-up + +One established implementation violation was repaired: the single-option +Request icon's local16px utility is removed only after its real branch is +attached to the existing shared14px content-size owner. No new geometry or +palette was designed, and production backend code is unchanged. + +Seven visual test files now follow the current Requests consumer, accepted +semantic table/gap owners and Series saved-order/controlled-disclosure behavior. +Superseded source/utility assertions were replaced with property/role/callback +coverage and diagnostic-specific negative fixtures. The affected nine-suite +execution (including shared button/icon checks) ran157 tests:153 passed, four +failed, no skips/todos/cancellations. Three failures were in the new icon checker: +it initially audited unrelated root palette aliases and mishandled PostCSS's +separate important flag. After correcting that checker, only its changed six +tests were rerun: all six pass. The other required Book ordering failure is +unchanged and unresolved; no full affected or cumulative pass is claimed. + +Current-batch/shared-style validators and changed-component lint pass; +formatting passes. The actual PostCSS configuration compiles the shared CSS +with zero warnings. Palette/theme, Book-order and other unreconciled failures +remain recorded. The earlier frozen full/native-JS receipts are retained; +this follow-up has only focused evidence and still needs rendered/human review. +Repository/toolchain prerequisites and full gate/build remain blocked. + +## Latest bounded preview checkpoint — October 2, 2026 + +This section supersedes earlier current-status descriptions above, not their +preserved diagnostic receipts. John limits further preview verification to the +Requests page, Series page, Discover's Recent Requests slider, and shared poster +styles across their consumers. A poster-role check does not authorize a whole-page +audit of each consumer. No broad backend repair or site-wide page sweep is approved. + +The two inherited network-isolation failures also reproduce on untouched upstream +v3.44.1 and the subsequently tested main. They do not establish that the visual +changes introduced a production defect. Original tests and failed receipts are +backed up. John's exact-file/target temporary diagnostic deferrals are opt-in; +outbound requests remain blocked and strict default behavior remains intact. +Deferred required failures are not a passing release gate. Reconcile the original +tests and the maintainer's coverage before final contribution acceptance. + +The retired CSS-representation assertions have been reconciled with the current +shared classes, variables and accepted semantic roles, retaining meaningful +behavioral safeguards and negative coverage. That source lane passed 410 tests +in 47 files, with no failures or skips. The previously completed component lane +passed 395 tests. These are separate receipts from an earlier exact source state, +not a complete cumulative pass for the final tree. The three broad native +receipt-recovery partitions were intentionally stopped when John narrowed scope; +their partial reports must not be counted as passing or restarted automatically. +Unrelated tooling findings remain documented rather than repaired or waived. + +A subsequent narrow cleanup removes competing card-padding ownership: main media +cards retain the existing shared main-padding owner rather than also consuming +inset padding. The overwritten local padding utility and duplicate blur declarations +were removed. The shared poster layout retains positioning while the shell retains +only its independent stacking role. Current main/inset padding remains 8px; +browsing posters remain 169.2px wide with a 2:3 frame. No component, production +backend, dependency, live configuration or provider data changed in this cleanup. + +Current focused receipts, executed once per final affected selection: + +1. Seven card/poster owner and menu checks passed, including negative mutation + checks (`mediaCardOwners.test.mjs` and the two selected menu contracts). +2. Thirteen native frontend poster badge/link and slider tests passed in three + files: `TitleCard/statusBadges.test.ts`, `TitleCard/bookDetailQuery.test.ts`, + and `utils/mediaSlider.test.ts`. +3. Nine native frontend Requests user-filter and Series availability-tone tests + passed in `Requests/requestStatusQuery.test.ts` and + `MediaDetails/AvailabilityValue.test.ts`. + +Those 29 tests have zero failures, skips, cancellations or todos. Native runs used +isolated source/config, read-only dependencies, no network, strict network guards, +and no temporary network-deferral flag. Formatting passed. Published CSS/test +readback matched the tested files; the complete source comparison found no +unexpected component/backend changes or deletions. + +Rendered review confirms Requests at 1440px and 390px, Series at the existing +desktop-sized viewport and 390px, and the request slider at 390px. Requests uses +36px/40px page-title typography above the breakpoint and 24px/28px below it; +cards retain 8px padding. Neither reviewed narrow page has page-wide horizontal +overflow. Poster positioning and frame dimensions remain intact. The desktop +collection-menu capture shows an opaque black surface extending beyond the main +card without clipping; Escape closes it. Request-slider Next moved the track by +one compact-card width, and Previous restored its disabled-at-start state. +The landscape request-summary cards are not portrait browsing posters. + +Legacy compact request-card utilities and remaining shared `@apply` are explicitly +not a Tailwind-free claim. Loading/error/empty variants, every alternate theme, +physical drag/touch, and unaudited pages are not newly certified by these captures. +No live provider mutation was repeated during this visual cleanup. Detailed source +hashes, receipts, recovery files and screenshot locations are in the recovery +journal. + +John explicitly accepted the visual test for this scoped cleanup on October 2, +2026, while on his phone, based on the previously proven CSS and absence of major +visual changes beyond utility/duplicate-owner cleanup. This is his approval of +the current bounded visual batch, not a claim that he newly inspected every +desktop/narrow screenshot or certified unreviewed themes, states or pages. +Earlier review gaps above remain recorded; this approval does not silently +certify physical drag/touch or other untested interactions. + +The complete strict gate/build, GitHub checks and publication authorization +remain pending. Visual acceptance does not waive deferred required test failures +or authorize a PR. CI success must be matched to the actual shared-gate inventory, +not assumed to cover every local check. + +## Finalization authority and latest accepted scope — October 3, 2026 + +John explicitly accepted the current preview and authorized final checks, a +contribution to `snapetech/seerrng`, monitoring relevant CI failures, deployment +of the tested preview build to his server, and bounded nightly cleanup. These +instructions supersede the earlier pending publication/deployment authority; +they do not waive a required failure or authorize unrelated backend repairs. + +The accepted candidate now uses the newer upstream 3.46.1 main at +`e7305281797cd7527c3b1c0a83ff144218ad506a`. Later accepted work includes shared +blue pinnable catalog filters and titles, the yellow watchlist visibility action, +the reused Series request tree and adjacent Episode Queue, a single Series +request entry, six-role Overview disclosure/order, and native shared CSS cleanup +for active Series detail/request/browse controls. The latest focused receipts +are not the cumulative release gate. Remaining utility-dependent conditional +overlays, settings-specific controls, physical drag/touch, and unaudited pages +are not newly certified or claimed Tailwind-free. + +Final independent review identified an API-contract mismatch: Overview expanded +the Series role set to six while the OpenAPI order schema still described five. +The schema must match the same six-role server owner before final validation, +including persisted user settings, request payloads and normalized responses. + +Publication must also follow the current contribution guide's requirement that +John supplies his own-word PR description and accurate AI disclosure. Automated +code/testing evidence does not supply that human-authored publication input. +If that input is absent, preserve the contribution on a verified remote branch +and report publication as pending rather than misrepresent authorship. + +## Required evidence at contributor finalization and maintainer integration + +Fill these fields with observed facts; unknown means pending, never assumed. + +1. Contributor remote/branch/commit; pinned maintainer target remote/branch/commit; + actual common ancestor; integrated commit; exact tested commit/source digest. + All commit IDs are pending until the actual authorized Git integration. +2. Pinned Node/pnpm identities, lockfile SHA-256, environment/platform, isolated + source/config/dependency mounts and actual network boundary. +3. Three-way changed-file inventory and conflict ledger: owner, newer upstream + behavior/security requirements, preserved interface rule, resolution and check. + Do not wholesale restore an older backend, component or lockfile. +4. Discovery plan versus executed logs: test file inventory, test totals, failures, + skips/todos, platform exclusions, lint/type/static/style results and build result. + `pnpm test` is test partitions only; `test:ci` is Vitest-only. Browser/Cypress + suites are separate. Linux is required for complete POSIX tooling coverage. +5. Desktop/narrow reference locations and John's acceptance per page/state, + including loading/error/empty, keyboard/focus/disabled, open menus and reorder. + An automated screenshot or computed-style result is not human acceptance. +6. Live Plex evidence separately from mocked native coverage; safe prerequisites + for unverified providers. Never copy credentials, runtime DBs or live config. +7. Explicit deferrals, unresolved failures and next gate. A failed required gate + blocks finalization; report unrelated backend failures before expanding scope. +8. Recoverable refs/archive identity and checksums, final source readback, and + applicable publication authority. Local preview publication is not PR approval. + +The contributor and Keith's AI must use the same checked-in instructions and +gate, adapted to the newer target without discarding its valid security/backend +fixes. If the target advances, pin the new head and repeat invalidated checks. diff --git a/docs/maintainers/interface-preservation-inventory.md b/docs/maintainers/interface-preservation-inventory.md new file mode 100644 index 000000000..d9859f2eb --- /dev/null +++ b/docs/maintainers/interface-preservation-inventory.md @@ -0,0 +1,126 @@ +# Interface Preservation Inventory + +Recovered from the development journal and original discussion on October 1–2, 2026. This complements the integration checkpoint. It records what to preserve, +not a claim that every feature passed final verification or human review. + +## Evidence labels + +- **Accepted** means an explicit recorded human decision for that named state. +- **Implemented** means the source/journal records implementation; it does not + establish current full-suite, rendered, physical-interaction or provider success. +- **Pending** means required verification/review is still outstanding. +- **Retired** means superseded; do not restore it during a conflict. +- **Opt-in** means a retained experiment, not a global production decision. + +October 1 journal entries establish sequence but lack exact clock times; do not +claim a precise noon cutoff. These are preservation families, not an exhaustive +file-level diff. Produce the actual three-way file inventory at integration. +Listed suites are existing coverage pointers, not fresh passing receipts. + +## Preserve across the contribution + +1. **Request Status and shared typography/layout.** Preserve the ordered pinnable + task/media/filter/sort sections, shared filter and action geometry, Request + modal spacing, semantic title/heading roles, card/table typography, boundaries + and truthful error/empty/retry states. Preserve page-level aggregate activity, + loading text and spinner; do not reintroduce nested activity owners. + Checks: `buttonGeometry`, `requestLayout`, `catalogLists`, + `catalogFilterOwners`, `filterMenuGeometry`, `discoverHeaderSpacing`, + `cardSpacing` and the current-batch contract. Final current page/state + references must be assembled; no blanket Request-page acceptance is inferred. + +2. **Browsing posters and controls.** Preserve fixed poster width/ratio, regular + title treatment, semantic media slots, heading/navigation alignment, hover + and shared action padding. Detail/credit posters remain distinct roles. + Preserve the development-only browsing Watchlist star/local toggle, accessible + pressed state and isolation from production writes; it is not Plex Watchlist. + Preserve watched/Associations/Blocklist placement, disabled help and permission- + aware blocked visibility. Prototype appearance/local interaction was explicitly + accepted October 1; later refinements retain pending visual review. + Checks: `buttonGeometry`, `iconOnlyButtons`, `watchlistPreview` and relevant + blocklist/list/hook tests. Do not restore the retired 24px zero-padding ghost- + button assertion; the current contract uses shared padding and poster roles. + +3. **Shared dropdowns, mosaics and compact selectors.** Preserve native authored + semantic anchoring/split parts, one border overlap, role-owned menu surfaces, + collection mosaic geometry, matching selection/availability glyphs and compact + browse/provider/region/filter controls. Preserve native flags/logos and source + behavior. Compiled Tailwind output with redundant `--tw-*` machinery was an + unfinished trial, superseded by authored owners—not the target design. + Checks: `seriesDetailsStyle`, `catalogFilterOwners`, `filterMenuGeometry`, + `buttonGeometry`. Noncompact forms, confirmation/association dependencies and + shell debt were not certified clean. Do not claim site-wide Tailwind removal. + +4. **Final shared Series tree.** Preserve outline structure, separate disclosure + and selection, one actual selection target, source-truth dates/watched state, + eligibility-aware counts, shared glyph colors, independent card/table settings, + parent-only spacing, stationary toolbar/header and rows-only scrolling. + Preserve production/lab reuse and exact season/episode-to-provider mapping. + Outline design was explicitly accepted October 2 at 02:49 MDT; integrated + playback/manual review remained pending. Checks: `seasonEpisodeTree`, + `seriesTreeData`, `seriesDetailsStyle` and provider mapping tests. + +5. **Independent tables and Media Server workspace.** Preserve descriptive Series/ + Movie summary versus expanded table configuration, responsive fallback and + card/table ownership separation. Keep one full disclosure workspace, mounted + selection state but no hidden hit targets, provider-derived logos, per-user + pin preferences and symmetric pin geometry. Preserve six persistent video + rating slots, truthful unknown/zero/cached-known values and safe media-correct + links; do not copy video semantics into other ratings. Checks: + `seriesDetailsStyle`, `videoRatings`, `detailDisclosureMediaServer`, + `detailDisclosurePinsMutation` and user settings tests. + +6. **Accepted action row and summary spacing.** The fully justified Series + Trailer/Associations/Report/ratings row was explicitly accepted at 03:32 MDT; + the corrected title-to-table spacing consuming card spacing was explicitly + accepted at 04:46. Preserve right-aligned content-sized native controls, + remaining-width tree, calendar weekday semantics, stacked server/device + playback and wrapping. Later width/date arrangements still need final renders. + Checks: `seriesDetailsStyle`, `buttonGeometry`, `seriesTreeData`. + +7. **Per-user disclosure reorder.** Preserve common control/panel order, one-second + hold, actual inert cursor-follow ghost, draft sibling reflow, valid-release-only + commit, quick-click and pin independence, rollback on cancellation/capture loss/ + blur/Escape, keyboard/reduced-motion access, identity-safe preferences and stable + panels. Preserve nullable reversible SQLite/PostgreSQL migration compatibility; + adapt to the newer target's migration ordering, never replace newer migrations. + Final physical drag/touch acceptance remained pending at 04:54 MDT. + Check: `disclosureOrder` and underlying settings/client interaction suites. + +8. **Native actions and bounded recovery.** Preserve independent Plex Watchlist, + Jellyfin/Emby Favorites and existing shared Collections, own-account authority, + exact selected-quality roots, confirmed desired state and no implicit request/ + download/synchronization. Personal Lists and collection creation proposals were + superseded by choose-existing Collections at 04:40 MDT. The October 2 recovery + restored the emergency baseline and retained bounded Collection corrections, + then Watchlist freshness and dropdown repairs. Live Plex proof is recorded in + the checkpoint; Jellyfin/Emby remain mock-only. Broad successor backend/visual + batches were rolled back; historical passing logs do not restore that code. + +## Retired and experimental states + +Retired tree trials include paired tables, rejected ledger/accordion/folder/tile +variants, Page 2, former trial heights, sticky/opaque header compensation, duplicate +child gap, card-title selection summary, two-second hold, highlight overhang and +tree-fill opt-in. The final compact tree uses the existing 186px frame, transparent +heading/rows, stationary header outside scrolling rows, border-only selection +feedback and table-role summary. Do not select an older value merely to satisfy +an old test. Replace superseded assertions with meaningful current checks and +negative fixtures; do not weaken behavior coverage. + +Page 6 retro amber/monospace remains an **opt-in reference**. Future global theme +work is deferred; liking that experiment never approved site-wide application. + +## Merge preparation agreement + +The original discussion at approximately 05:03–05:30 MDT on October 2 requested +recoverable checkpoints, journal/tests/accepted references, the same checked-in +standards and checks for both AIs, separate Style Standard and Fix-It documents, +AGENTS routing, and a clear forward-merge procedure. Implementation was explicitly +authorized at 05:30. The approximate 04:58 history marker identifies the nearby +conversation, not the exact start of the merge discussion. + +Review a page or two per batch, repair only established violations automatically, +and bring new design choices or unrelated backend failures to the maintainer. +Final cumulative checks, production compilation and human visual acceptance +remain distinct required gates. This inventory is not publication authority. diff --git a/docs/maintainers/ui-fix-it.md b/docs/maintainers/ui-fix-it.md new file mode 100644 index 000000000..0240dd1b4 --- /dev/null +++ b/docs/maintainers/ui-fix-it.md @@ -0,0 +1,117 @@ +# UI Fix-It + +Use [UI Style Standard](ui-style-standard.md) as the target contract. This file is the generic audit and repair workflow: it detects asset-role drift, corrects established rules, and raises unapproved choices. Do not copy the full standard here, merge the two documents, or turn this file into a list of exact labels and examples. + +## 1. Establish authority and scope + +1. Follow the project's startup, repository, development and release policies. Identify the source actually serving the approved target. Read back that authoritative source before editing; a nearby checkout or old staging snapshot is not authority. +2. Confirm the requested page/assets and whether the request authorizes diagnosis, implementation, publication, provider mutations or only a visual prototype. Persistence does not broaden authority. Preserve dirty work and coordinate shared-file ownership. +3. Read the current standard and task-specific records. Distinguish latest accepted decisions from superseded trials and upstream rules. If they conflict, gather the relevant source and acceptance evidence and ask; do not silently choose a new standard. +4. Keep a bounded audit manifest: entry component, rendered dependencies, active normal/conditional branches, shared owners, deferred roles and validation needed. Expand only when a rendered dependency or ownership relationship requires it. +5. Separate source cleanup from design changes. An established missing owner can be repaired automatically; a new size, palette, position, behavior or asset family needs discussion. Report uncertainty as a numbered issue with the role, evidence, effect and proposed options. +6. Keep review-batch scope separate from preservation scope. A Series-first audit must still retain previously accepted Request-page and shared heading/status/button/poster changes. Inventory those owners and their existing checks rather than rerunning an unrelated whole-app or backend investigation. + +## 2. Inventory by asset role + +1. Classify page identity, heading containers, aggregate status, main/inset/compact cards, copy, tables, posters, actions, filters/selects, dropdowns, messages, overlays, selection/tree, ratings, and native saved/reorder interactions. +2. Inspect imports and rendered shared components, not only the page's literal `className`. Include aliases, template strings, conditional branches, helper-produced classes, component class props, transition props, inline styles, CSS modules, CSS-in-JS and responsive states. + Classify actual rendered variants before repairing an imported dependency. A hidden shared summary or another media type's branch is not an active consumer. Track those separately rather than widening the batch or declaring the complete shared component migrated. +3. Trace headings and titles through their typography roles and active translations/generated helpers. Detect Title Case and punctuation drift by role. Do not create checks keyed to exact example titles, badges, message strings or media names. +4. Search Tailwind use in JSX and styles, including `@apply`, utility aliases, generated `--tw-*` machinery and nested state/media rules. A zero literal-utility count in one component is not a completed dependency audit or native-CSS conversion. +5. Map each observed presentation property to its existing shared owner. Include typography, dimensions, padding/gap/margins, alignment, ratio/crop, border/surface, icon geometry, state effects, transitions, scrollbars and breakpoints. +6. Distinguish external/native classes and meaningful data geometry from presentation utilities. Country flags, provider branding, portrait/mosaic arrangements, runtime pointer bounds and native identifiers are not automatically removable because their names look unfamiliar. +7. Follow source/generated reference relationships and active palette/mode variants. Preserve opt-in reference pages; do not audit a generated preview as if it were the only source or silently adopt its experimental style. +8. Include loading placeholders in the asset inventory. Trace both whole-section loading and per-item lazy/partial metadata branches to the card family they replace. Compare them with established loaded examples at the family's breakpoints; do not assume a shared placeholder component selects the correct variant in every caller. + +## 3. Find ownership and layout regressions + +1. Inspect the complete cascade: root variables, variants, grouped selectors, specificity, inheritance, pseudo-states, responsive rules and compiled output. Identify missing role attachment before deleting a local override. + Verify that every custom property consumed by a migrated owner has an authored definition or documented runtime source. Framework palette names are not automatically CSS variables; undefined references can silently erase borders and surfaces while source class checks pass. +2. Check that controls have both appearance and a size owner. Raw `app-button` is insufficient. Reuse standard action/filter/poster/form roles; do not restore a local icon rule or create a class per button. Check action-state branches for an explicit appropriate palette and an icon that describes the offered action, not the opposite state. Use general color variants rather than borrowing an unrelated provider class. Trace single-option and multi-option branches separately; deleting a local SVG utility is unsafe until the actual branch consumes the shared size owner. Add negative coverage for missing ownership and competing local dimensions. +3. Detect duplicated CSS and Tailwind property ownership. If the correct shared class already supplies a property, remove the competing declaration; do not copy it into another owner. Trace shorthand/longhand and inherited variables before deciding it is redundant. + A semantic shell does not prove its children or state variants are migrated. Inspect value/option descendants and CSS-generated utilities as well as the root. Move truncation and selection emphasis into the existing family and consume semantic state; preserve portal positioning, disabled behavior and keyboard focus. When retiring generated state variables, trace every theme/variant consumer and reconnect it to the authored state owner instead of dropping its palette feedback. + Distinguish framed and standalone consumers of a shared surface. An established frame owns border and corner geometry; retain standalone legacy geometry through a low-specificity fallback that excludes framed consumers, rather than duplicating frame properties in the surface role. Compare effective rendered properties before and after migration; generated zero-shadow layers and unused border-color serialization are not visible geometry changes. Verify focused child controls and their parent state together, including higher-specificity palette variants. + For action menus, trace the shared opaque surface, clipping ancestors and complete stacking-context chain, including decorative pseudo-element frames. Verify a menu crossing the card edge, not only its own background or z-index; preserve ordinary closed-card clipping. Report an unapproved viewport preset instead of inventing one. +4. Check page spacing as a boundary: search/header padding and offset, page margins, title/heading container padding, line height, card spacing and the next content edge. Reject compensating spacer nodes and offsets. Separate card-to-card or card-internal gaps from heading-to-content boundaries before replacing a grouped gap rule; those roles can consume different established owners. Remove superseded padding at every responsive breakpoint once the shared surface supplies it, without changing unrelated typography or poster geometry. +5. Check typography together with its layout owner. Table headings/values must share the table family while independent table-layout variables prevent changes to one table type from breaking another. Compact cards remain fixed-size, not item-dependent. +6. Check shelf headings and actions are actual shared-row children with baseline alignment. Check global poster dimensions/ratio and semantic region placement in all in-scope media renderers; distinguish compact credit/detail posters before migrating. +7. Check fixed headers/frames against rows-only scrolling, shared tracks, gutters and dividers. Check selection glyph visible diameter, not just equal SVG boxes. Preserve separate availability, watched and selected semantics. When reusing a selector for another action, retain its existing submission, quota and destination rules; verify action-specific eligibility independently of library availability, exact payload identity, clear/all/disclosure behavior and metadata failure/retry without losing selections. When replacing non-native controls with native buttons, reset browser defaults through the existing CSS owner and check focus, geometry and one activation per click/key. +8. Check overlays and dropdowns for duplicate surfaces, backdrop/animation owners, clipping and reduced-motion behavior. A transition migration must preserve interaction, not merely remove utility tokens. +9. Check each message's actual source and meaning, content-fit geometry, severity, title/body ownership, real recovery callback and cached-content behavior. Exercise long feedback in content-sized flex/grid regions: it must wrap below its control without determining sidebar width, crushing adjacent content or creating duplicate gap/margin ownership. A blank successful list is not automatically an error. +10. Check one aggregate page status covers actual fetch/search/panel/mutation activity and clears idle/unmounted contributions. Do not add inner spinners or move searching back to the global search input. +11. Check decorative masked frames separately from body surfaces, mode-independent artwork readability and actual control geometry through focus/selection. Native-select operating-system limits must be reported rather than disguised as a verified CSS theme. +12. Compare loading and loaded geometry through their actual shared CSS owner, including computed width, baseline height, box sizing and responsive variables. Detect copied skeleton dimensions, missing variants and competing utility/inline sizing. Reuse the existing family layout and keep loading paint separate; preserve content-driven growth and error recovery. A matching screenshot at one breakpoint is not proof of shared ownership. + +Compare reused cards in their original and new contexts at the user's actual +CSS viewport, including display scaling. Check both sides of layout breakpoints; +a larger review viewport does not prove the user's arrangement. Preserve +established proportional card sizing, allocate the remainder after the shared +gap to companion content, and consume standard padding rather than a fixed-width +sidebar or copied geometry. For aligned adjacent cards, stretch the companion +layout and let its card consume the remaining height after controls and shared +gaps; do not copy the primary card's height into another sizing owner. Preserve +natural wrapping for longer or translated copy rather than clipping it. + +## 4. Apply established repairs + +1. Prefer the smallest role attachment/removal that restores an approved owner. Reuse existing semantic variables and variants; preserve data, actions, permissions, links, click targets and component APIs. +2. Convert scoped utility-owned styling into authored native CSS at its semantic owner. Do not paste compiled Tailwind output, retain redundant `--tw-*` state, or rename individual utilities into semantic-looking aliases. Keep one state/transition owner and reconcile downstream focus palette consumers deliberately. +3. If the current shared owner is incomplete, propose a narrowly reusable extension. Explain which roles need it, the existing property owner and how duplication will be avoided. Stop before inventing a new design value or page-specific exception. +4. For an approved global migration, update all in-scope consumers, CSS variables/selectors, imports, tests and current guidance together. Remove a retired owner only after active consumers are gone. Do not rewrite historical journal entries. +5. Keep action arrangement layout-dependent. Preserve approved row/order choices while migrating appearance; do not impose a retired universal playback/ratings/quality layout rule. +6. Keep experimental design variants opt-in and clearly classified in records. Do not promote a Visual Lab example to site standard without acceptance. Review fixtures preserve the actual shared production components and interactions without live data writes. +7. Do not make undisclosed behavior changes as a styling repair. New filters, matching rules, permission changes, provider actions, saved memberships and source semantics require their own explicit authority and tests. + +## 5. Verify interactions and truthfulness + +1. Exercise role/state combinations with isolated fixtures or mocks: enabled, disabled, hover, active, focus, busy, empty, failed, cached refresh, partial data and relevant breakpoints. Verify user-facing controls are visually functional where the review asks for them. + Follow the universal Website Development Policy's browser-tab ownership rule: use dedicated ordinary inspection tabs, leave the work owner's existing tabs untouched, and close temporary inspection tabs afterward. Verify preview origin/source and media-specific optional controls before diagnosing a screenshot or wrapped row. +2. Preserve accessibility: names, descriptive help, disabled-tooltip access, keyboard behavior, native semantics and reduced motion. Detect nested buttons, double selection, event propagation into posters and invisible-but-interactive panels. + When consolidating segmented actions into a single screen-entry control, + verify that the whole control opens the intended screen, permission/service + eligibility is preserved, and navigation does not perform an approval or + submission. Keep format choice inside the destination through its existing + controls, and test unmodified consumers of the shared component separately. +3. Verify ratings retain persistent provider slots and exact unknown placeholders, valid zero and cached-known values; reject fabricated/out-of-range data and unsafe or media-incorrect links. +4. Native saved actions must prove own-user/token provenance, strict inputs, selected-quality trusted root, verified provider authority, sanitized errors and confirmed membership. Collections/Favorites/Watchlist are not interchangeable labels. No real provider/account mutation is allowed merely to verify a visual cleanup. +5. Client tests prove inactive prototypes mount no native hooks and busy/unavailable controls cannot mutate; authorized state reads may continue while a loading control is disabled. Cover identity/provider/title/quality cache separation, fresh preflight, double-click protection, stale-response rejection, failure reset, confirmed-only state and GET-only recovery. Check actual fetcher and middleware contracts rather than adding unsupported query parameters or assuming raw query types reach the route unchanged. + Trace mutable membership through client request freshness, API cache policy and service-worker interception. Cover Add and Remove, confirmed readback and fresh already-completed actions; a cached no-op is not provider verification. Keep live authorization and live evidence distinct from mocked coverage. +6. Reorder tests cover quick-click versus one-second hold, cursor-following inert ghost, draft versus committed order, valid release, outside/Escape/blur/cancel/capture-loss rollback, stable panels, keyboard/reduced motion, save failure and identity changes. Mocked tests do not prove physical drag feel or touch support. + Register new disclosure roles in the shared saved-order and scoped pin + contracts, not only their visible controls. Verify default insertion preserves + the relative order of older roles, explicit saved placement wins, each panel + follows committed order, and hidden/embedded consumers remain valid. Moving + content between disclosures must preserve its links, attribution and single + ownership. Distributed rows use the existing layout owner with wrapping and + standard minimum gaps; they must not change control geometry. + For saved disclosure controls, test pin-to-expand and unpin-to-collapse separately from manual heading toggles. Capture previous/next state transitions before queueing React state updates; do not compare an advancing mutable ref inside a deferred updater. Cover delayed saved-state arrival, independent sections and optimistic rollback with isolated fixtures. Keep an explicitly staged rollout limited to the approved consumer until its human verification gate is accepted. + +## 6. Run the shared gate efficiently + +Before the expensive cumulative run, establish a complete pinned repository tree +and the prerequisites required by its current test inventory. A runtime app +volume may omit workflow/release/dotfile fixtures even though the app runs. +Check the snapshot manifest against those inputs; do not reconstruct missing +files from a different release or guessed ancestry. Linux platform selection is +not proof that Bash/Git/Python, CLI tools or GNU filesystem options are installed. +Use a suitable disposable toolchain and record its versions. Plan discovery +does not certify these external prerequisites. Missing inputs block finalization, +not permission to skip suites, change CI or install tools into the live preview. + +1. Inspect `pnpm validate:development --plan`. During an approved edit batch run affected focused checks and label preview evidence as iterative. Run the full gate on the exact final review candidate, before commit/PR, and on the integrated tree after conflicts. Public build/development scripts and the commit hook remain gated; `pnpm build` includes the gate, so do not run another complete gate immediately beforehand. Internal compile/server aliases are not finalization shortcuts. `pnpm test` shares test partitions only; `pnpm test:ci` is Vitest-only. Browser/Cypress checks are separate. Retain discovery and executed results separately, including counts/skips/platform exclusions; Linux is required for full POSIX tooling parity. Existing CI remains unchanged. +2. Run focused source/AST checks, effective CSS checks and behavioral tests for changed owners and affected consumers. Reuse existing focused tests instead of building a second verifier architecture or performing an unrelated whole-site audit. +3. Assertions target roles, relationships and actual behavior. Parse CSS correctly across grouped/nested rules; do not use stale utility-name assertions, naive first-brace extraction, or exact text examples as proof of styling. +4. Run proportional lint/type checking and compilation for changed source types/style owners. Check service health only after authorized publication. Compile/type/HTTP success is not human visual acceptance or a successful provider round trip. +5. Diagnose a failing check. Repair actual behavior or update a genuinely superseded assertion with equivalent coverage and acceptance evidence. Never weaken or delete a live behavior check merely to obtain a passing count. +6. Avoid repeated full builds, rerunning unchanged suites, broad source copies and serial work that can safely be batched. Keep one authoritative readback, one owned edit batch, focused checks and a final cumulative verification. Communicate shared-file barriers and completion promptly. +7. If scope, safety, source identity or gate coverage is unresolved, fail closed: do not finalize or claim completion. Record unrelated baseline failures and request direction before expanding scope; do not automatically repair unrelated backend code. Deferral never converts a failed required gate into a pass. + +## 7. Record, report and evolve + +1. Update the recovery journal for every change in the same work unit. Record role/owner, exact files, old/new known values, authoritative source identity, publication target/archive, checks, remaining limitations and accepted/pending visual status. A checkpoint or this file is not a substitute. +2. Retain rollback archives and ownership-uncertain assets. Do not infer old staging, idle previews or comparison sources are disposable. Keep credential-containing recovery material out of repository/document content. +3. Report outcomes in a numbered list when human review is needed: fixed established drift, verified scope, unresolved choices, explicit deferrals and next visual gate. Say whether code is staged, published, functional or static; do not conflate these states. +4. Keep coverage honest. Count and list remaining active utilities/owners by role and branch, including dynamic/untraced cases. Never report the page or site Tailwind-free while deferred dependencies or active scoped `@apply` remain. +5. When a new rule is accepted, add the reusable subject/asset rule to the standard and detection/repair method here. Keep exact examples and chronology in the journal, not in normative matching logic. + For every repair, consider whether it exposes a reusable detection or ownership gap. Improve the relevant method rather than adding a class/text-specific exception or copying an isolated example's values into a new rule. Existing accepted components illustrate the standard; their CSS owners and variables remain the contract, and tests should enforce relationships and behavior rather than example labels. +6. Before marking complete, verify the requested scope is implemented, source readback matches the authorized target, all required gates passed, records are current, and the human visual/provider gates are clearly accepted or pending. A pending required gate means the final candidate is not yet complete. diff --git a/docs/maintainers/ui-forward-merge-guide.md b/docs/maintainers/ui-forward-merge-guide.md new file mode 100644 index 000000000..d370ac38d --- /dev/null +++ b/docs/maintainers/ui-forward-merge-guide.md @@ -0,0 +1,107 @@ +# Forward Integration of the Interface Work + +This is an integration procedure, not permission to publish or a guarantee of +zero regression. Preserve both the maintainer's functional/security changes and +the contributor's accepted visual/interaction contract. + +## Inputs and checkpoint + +1. Fetch the authorized contributor branch and `snapetech/seerrng` main through + authenticated Git. Resolve both to full commit IDs. Verify remotes, branch + ownership, a clean integration checkout, and the actual common ancestor. + Record the IDs before editing. Do not infer provenance from `package.json`. +2. The current approved laptop source reports version 3.46.1, based on upstream + main `e7305281797cd7527c3b1c0a83ff144218ad506a`. The earlier `v3.44.1` tag resolves + to `5b0859a8ba30380661033ea4c7d06c77be970032`; use this as a cross-check, + not a substitute for the PR's real ancestry. During earlier preparation main was + observed at `da85baa8461c183d785d4ca5df264ad0ba79f24c`. Fetch and pin the + current target when integrating; that observation is not a permanent target. +3. Save recoverable refs/source and produce a three-way changed-file inventory. + Do not overwrite a dirty checkout, discard contributor commits, or include + live config/databases/secrets. Work in an isolated integration branch/worktree. +4. Read `AGENTS.md`, both UI documents, `CONTRIBUTING.md`, the PR's acceptance and + verification record, and applicable task instructions completely. Inventory + current gate/test coverage and known unresolved work before resolving conflicts. +5. Start the evidence record from + [Interface Integration Checkpoint](interface-integration-checkpoint.md). + The first review batch is not the complete PR scope: preserve Request-page + work and the shared heading/status/button/poster/layout owners too. The + checkpoint is a coverage map, not proof that its pending gates passed. + +## Reconcile cumulative changes + +1. Rebase the contribution onto the pinned target main as required by the + contribution guide, or use the maintainer's explicitly approved equivalent + integration workflow. Main already contains its intervening commits: do not + cherry-pick all of them again. Do not rewrite someone else's published branch + without authorization. Integrate coherent commits/batches with checkpoints. +2. Review each overlapping component, stylesheet role, API route, schema, entity, + migration, test, package script, and instruction file semantically. Never use + blanket `ours`/`theirs`, replace the newer lockfile with an older one, or restore + a whole old component just to recover its appearance. +3. Keep target-side security/provider fixes and new capabilities. Adapt the + contributor's semantic CSS/structure to those implementations. Preserve + selection/provider/quality identity, account authority, disabled/error behavior, + and new migration ordering for both SQLite and PostgreSQL. Never run migrations + against production as a merge test. +4. Preserve the accepted appearance through its role owners: page/layout/status, + card/table/list/poster/control families. Remove redundant overrides rather than + adding compensating padding, transforms, utility strings, or new exceptions. + Button placement is layout-owned, not a universal ordering rule. Record a + genuine conflict requiring a new design instead of silently changing acceptance. +5. Keep the standard and fix-it documents separate and synchronized with source. + Preserve all valid regression/security tests. Connect new suites to the shared + gate. If a historic assertion encodes a superseded trial, document that fact + and replace it with a current rule/behavior assertion with negative coverage. + A check must detect the regression it claims to prevent. + +## Apply the same acceptance procedure + +Establish complete repository fixtures and native tool prerequisites using the +fix-it procedure before the cumulative run. An app-volume archive or bare Node +image is not a complete repository/toolchain receipt. Resolve missing fixtures +from the actual pinned Git inputs; never invent them, copy another release's +workflows or waive required checks. Retain the source manifest and tool versions. + +1. Follow the fix-it audit against changed assets and their affected consumers, + including effective CSS cascade, structural ownership, dynamic/secondary class + props, hit areas, fixed headers versus scrollers, state, and narrow layouts. + Run `pnpm validate:development --plan` to inspect connected suites, then + one `pnpm build` on the exact integrated tree; it runs the complete validation + gate before production compilation. Do not run the full gate twice. Any failed required + check blocks finalization; do not bypass a hook or redefine success. +2. Retain validation and compilation results with the target's pinned + runtime/dependencies. If validation and compilation are separate phases, + retain an exact unchanged-source receipt before using `build:compile`; + that internal command does not prove validation by itself. + Preserve existing CI workflows/checks. Run applicable + browser/integration tests in disposable config; record prerequisites that are + unavailable. Never report a skipped suite as a pass or a mock as real-service + verification. Native saved actions mutate only after an explicit user action. +3. Compare actual desktop and narrow renders with the accepted reference, including + initial/refresh loading, empty/error/retry, selection/expansion, hover/focus, + disabled actions, pins, drag cancellation/drop, and provider-specific controls. + Existing UI tests reduce review effort; they do not establish pixel identity. +4. If main advances before finalization, fetch and pin the new head, reconcile the + additional changes, and repeat the invalidated checks/review. Do not claim the + previous build verifies code added afterward. Record the final exact target, + contributor, integration, and tested commit IDs. +5. Summarize preserved features, resolutions, commands/results, unverified native + paths, visual acceptance, and remaining scope. Obtain the required human review, + use the project PR template and AI disclosure, add/preview release notes, and + finalize only under the maintainer's publication/merge authority. + +## Evidence and recovery + +Keep the source checkpoint, changed-file inventory, acceptance reference, gate +inventory/results, and conflict-resolution ledger associated with the reviewed +commit. On a regression, reproduce it, repair its authoritative owner, extend a +role/behavior check, and update the standard/fix-it records. Recover from saved +refs or archives in an isolated checkout; never erase newer work to restore a +visual baseline. The shared gate enforces covered rules on both sides; uncovered +rules and live service behavior remain explicitly reviewable boundaries. + +Complete the checkpoint's commit/runtime/conflict/evidence fields during the +real integration. Do not invent PR IDs, ancestry, acceptance or passing results. +Report unrelated failures before attempting an out-of-scope backend repair; +retain newer target fixes and defer the integration if a required gate fails. diff --git a/docs/maintainers/ui-style-standard.md b/docs/maintainers/ui-style-standard.md index 16d045473..cf171f5c1 100644 --- a/docs/maintainers/ui-style-standard.md +++ b/docs/maintainers/ui-style-standard.md @@ -1,283 +1,169 @@ -# Seerr UI Style Standard - -This document defines the shared visual and interaction rules for Seerr's refreshed user interface. New controls should reuse the shared components and classes described here instead of introducing page-specific variants. - -## Scope - -The standard applies to user-facing media discovery, detail, request, request-status, blocklist, issue, global search, Settings, and Users surfaces. Settings and Users retain their purpose-built layouts while using the same shared control styling. - -## Shared Control Rule - -- Application action buttons, badges, split-button halves, and poster controls use the shared 16-CSS-pixel outer height. Filter and sort buttons, filter dropdown triggers, filter search fields, and other compact page-filter controls use their established 20-CSS-pixel outer height. Component aliases may express intent but must resolve to the applicable shared height; do not introduce page-specific sizing overrides. -- A filter button's normal one-pixel border is part of its fixed 20-pixel box. Selected and keyboard-focus emphasis renders outside that box with an outline or outward ring; it must never consume content space, thicken inward, shrink the label/icon area, or change the control's measured height. -- The profile/avatar menu is the single menu-row height variant: its action rows use a dedicated semantic CSS class at 20 CSS pixels. Its trigger and the other controls still use the shared 16-pixel height. The login page retains its existing layout and sizing. Browser/OS-rendered native select popup rows cannot be sized reliably by application CSS; their application-styled triggers still follow this rule. -- Reusable control and card styling belongs in semantic classes and tokens in `src/styles/globals.css`, not visual inline styles or page-local copies. Shared buttons and badges have a six-pixel corner radius, nine pixels of horizontal content padding, and a six-pixel icon-to-text gap; poster-only classes stay separate but match those measurements today. Standard application buttons use the shared 55-percent resting surface, 70-percent hover surface, and 85-percent active surface. Poster controls intentionally use a 70-percent opaque surface in every state so artwork cannot make their labels unreadable. Selected filter and sort controls retain their solid active surface. Application-styled dropdown menus use one solid-black surface, shared border and corner radius, and solid selected-row treatment. The playback menu and the optional split Play arrow use that same menu styling. - -## CSS Ownership and Agent Contract - -- `src/styles/globals.css` is the canonical application stylesheet for shared visual tokens and semantic classes. `src/styles/visual-lab.css` is the Visual Lab source, and `public/visual-lab/visual-lab.css` is its generated browser-ready artifact; regenerate the public file whenever the source changes. Do not hand-edit only the compiled artifact, and do not promote a lab-only class into production by copying only part of its recipe. -- Components select semantic classes; they do not recreate their visual properties. Inline `style` objects, inline Tailwind visual utilities, arbitrary-value Tailwind visual classes, component-scoped style blocks, and page-local copies are prohibited for colors, gradients, typography, control dimensions, padding, borders, radii, opacity, backgrounds, shadows, filters, blur, and interaction states. -- Tailwind utilities in component markup are limited to structural layout and responsive placement: display, grid/flex flow, order, wrapping, responsive visibility, and container sizing when no shared component class owns that structure. If a spacing, size, or layout rule repeats or belongs to a shared component, move it into a named semantic class in `src/styles/globals.css`. -- Runtime values that cannot be represented statically, such as measured progress width or a user-selected live color swatch, are the only inline-style exception. Document the reason at the call site. A fixed value is not runtime data. -- Standard application buttons use `app-button*`; poster artwork controls use `poster-control*`; filter and sort controls use `app-filter-button`. These families are independent. Copy an approved property recipe into the correct family and never attach one family's class to another family. -- Class names must be explicit and statically discoverable. Do not construct partial palette class names dynamically. Map runtime roles to complete class strings so validators and future agents can audit every style. -- Any new shared visual rule requires three coordinated changes before build: the semantic CSS, this standard, and a validator rule that rejects the obsolete or forbidden pattern. Run `pnpm ui-style:check` and `pnpm current-batch:check` before visual review. - -## Final Palette and Treatment System - -- The finalized spectrum families are `gray`, `black`, `blue`, `aqua`, `purple`, `plum`, `pink`, `red`, `orange`, `yellow`, `lime`, and `green`, with `dark`, normal, and `light` variants where defined. Neutral specials are `steel`, `metalic-chrome`, and `liquid-chrome`. -- The canonical application base-color tokens are `--palette-red`, `--palette-orange`, `--palette-yellow`, `--palette-lime`, `--palette-green`, `--palette-aqua`, `--palette-blue`, `--palette-purple`, `--palette-plum`, and `--palette-pink`, together with their `-dark` and `-light` forms in `src/styles/globals.css`. Use those tokens instead of inventing a nearby hue. -- The spellings `metalic-*` and `gradiant-*` are deliberate established class names. Do not silently correct them to `metallic-*` or `gradient-*`; doing so creates a second incompatible class vocabulary. -- A solid treatment uses the matching `solid-*` palette class. A smooth gradiant uses `gradiant-*` and always travels from the base color at the bottom-left to the near-white tint at the top-right. Its CSS angle is `45deg`; do not mirror, reverse, rotate, or add a white bank through the middle. -- A metalic treatment uses `metalic-*` and the shared narrow white-reflection band recipe. New hues change only `--metal-dark`, `--metal-middle`, and `--metal-light`; they do not move the reflection stops. `metalic-chrome` retains its approved linear chrome pattern. `liquid-chrome` retains the finalized eight conical reflections centered at 130 percent below the text and must not be tiled, mirrored, or split into role-specific position classes. -- Text roles keep their role geometry separate from their color treatment. The Visual Lab role classes are `page-title`, `media-slider-title`, `card-title`, and `card-heading1`; named treatment references use `page-title-*`, `card-title-*`, and `card-heading-*`. Apply one role class plus one treatment class instead of creating a combined page-local imitation. -- The Visual Lab Title Text, Borders, and Buttons pages each expose complete Solid, Gradiant, and Metalic treatment sets. Their shared `All | Solid | Gradiant | Metalic` filter appears directly below the page title, defaults to All, and remembers the selected family while moving among the three pages. Gradiant buttons use the same `45deg` bottom-left-to-top-right transition as title text and borders; only their dark readability overlay changes between interaction states. -- Visual Lab treatments are CSS-only. Do not add raster background assets, image-backed fills, inline SVG gradients, or inline presentation values to reproduce a palette treatment; define the reusable recipe in `src/styles/visual-lab.css` and regenerate `public/visual-lab/visual-lab.css`. -- Visual Lab palette pages are direct-access review tools and are intentionally absent from desktop and mobile navigation. Their absence from the menu does not make the shared palette disposable. - -## Compact Filter Controls - -- Filter, sort, and search controls use the shared control height, a one-pixel blue border, rounded corners, 12-pixel text, and the shared translucent indigo-blue background. Paging controls retain their established page-navigation geometry. Do not substitute the near-black gray control surface or restore a page-local taller filter. -- Closed filter dropdowns size to their label and current value rather than using a shared fixed or minimum width. Keep them content-width and allow the opened option list to expand independently when an option needs more room. -- Composite controls place the title-cased label in a separate left segment and the selected value or input in the right segment. -- When a dropdown has a non-default value, only its label segment receives the active indigo highlight. -- Application-styled dropdown lists show no more than ten rows before scrolling and use the shared option-row height. -- Rating dropdowns are the row-limit exception: they display `Any` plus every supported star threshold without a scrollbar. Their selected values and scored options show only the five-star visual, without a numeric score. -- The default display value is `Any` unless the control has a more meaningful neutral value. -- Search controls use the `Keyword Search` label and white, size-matched entered text. -- In page filter rows, Keyword Search uses a total width of 18 rems (`w-72`) when space permits and shrinks only when the available row is narrower. -- Multiple unquoted search words use implicit AND behavior. A user may type the word `AND`, but it is not required. Quoted phrases remain a single search term. -- Keyword Search results must contain every entered term in meaningful visible identity metadata. Movies and Series match title fields; Music matches album title, artist, and subject tags; Books match title, author, and Genres. Do not accept hidden provider-only fields such as a book publisher as the sole reason for a match, and do not replace provider relevance order with popularity ranking while a keyword query is active. -- Page-level Keyword Search controls report their debounce and results-loading activity through the shared `Searching` indicator beside the application header search. Keep that indicator in the header and do not add duplicate page-local spinners; it remains visible until every concurrent search source has finished. -- Reset controls use the shared `Clear Filters` label and selected or dimmed states. Selecting one clears the other filters for that page, including both the routed Keyword Search parameter and its local debounced input value so search text cannot persist or write itself back. Do not add duplicate media-type reset controls. On Books, `All Books` is not a reset control: it is the required neutral member of the separate `All Books | Books | Audiobooks` format group. -- Global Search separates media type from the regular filter controls. A `Media Filters` heading contains `All | Movies | Series | Books | Audiobooks | Music`. The following `Filters` row contains `Clear Filters`, title visibility, Keyword Search, and then the selected media type's discovery controls in their established order; the entire row wraps naturally as space requires. `All` stops after Keyword Search. Changing media type clears controls that belong to the previous type, while Clear Filters resets the keyword, media type, contextual filters, and Search sort state together. -- Movies use `Clear Filters`, title-visibility, and one segmented `Quality Available | All | HD | 4K` control on the first filter row. Keyword Search begins a deliberate second row, followed by `Release Date`, `Genres`, `Content Rating`, `Studio`, `Runtime`, `TMDB Rating`, `Language`, and `Streaming Services`. Movies do not expose a `TMDB User Votes` filter. -- Series use `Clear Filters`, title-visibility, and one segmented `Quality Available | All | HD | 4K` control on the first filter row. Keyword Search begins a deliberate second row with `Status` immediately to its right, followed by `Release Date`, `Genres`, `Content Rating`, `Network`, `TMDB Rating`, `Language`, and `Streaming Services`. The shared genre selector preloads the complete type-specific TMDB genre list before applying text filtering. Series do not expose duplicate reset controls, `Runtime`, or `TMDB User Votes`. -- Music uses `Clear Filters`, title-visibility, and one segmented `Quality Available | All | MP3 | FLAC` control on the first filter row. Keyword Search begins a deliberate second row, followed by `Release Year`, `Release Type`, and `Genres`. Do not present provider relevance/popularity as a user rating. Add `Rating` or `Language` only after music discovery supplies reliable, filterable metadata for those fields. -- Books and Audiobooks place `All Books | Books | Audiobooks` under a dedicated `Media Filters` heading. Their following `Filters` row flows continuously as `Clear Filters`, title visibility, Keyword Search, `First Published`, `Genres`, `Rating`, and `Language`, wrapping naturally as space requires. -- A specific Quality Available choice shows only titles whose Arr-backed library state includes that quality. Movie HD and 4K filters use the current cached Radarr library response whenever a matching synchronized Radarr service is configured: only entries with `hasFile` belong in the result, monitored entries without files are excluded, and the visible cards use Radarr's title metadata plus the authenticated Radarr cover proxy. This live result overrides stale copied-database availability; only the visible page's persisted Seerr relationships are hydrated. Series continues to accept complete or partially available scanned video titles. MP3 and FLAC use the separately persisted Lidarr destination availability and configured profile label. `All` is the neutral default and does not hide available titles beyond the user's existing global discovery preference. -- The Books and Audiobooks `Media Filters` section uses three format buttons in this order: `All Books`, `Books`, `Audiobooks`. `All Books` is selected by default. -- Discovery filter controls use the concise `Language` and `Release Date` labels for both Movies and Series. -- Time Period lists place `All Time` first and use it as the initial neutral value. -- Composite controls align vertically with adjacent buttons; flex rows must not stretch or top-align an individual dropdown or search control. -- When the primary discovery controls intentionally start a second filter row, that row uses the shared five-pixel gap. Do not create the break with an empty full-width flex item because the row gap is then applied twice. -- Compact searchable dropdowns use the same shared field height as fixed-option filters. Their value, search caret, and chevron remain vertically centered, and the legacy React Select indicator divider is hidden. - -## Task and Regular Filter Rows - -- Separate every filter category with the shared 20-pixel vertical gap. Apply the same spacing between Task Filters, Media Filters, Filters, and Sort By rather than tightening selected category transitions. -- `Task Filters` contains only workflow-state controls. `Clear Filters`, with the no-symbol icon, is always the far-left control in the first row; counted summary buttons follow it. -- Request Status uses one wrapping Task Filters row in this exact order: `Clear Filters`, `All Requests`, `Completed`, `Incomplete`, `Active`, `Needs Attention`, `No Release Found`, `Failed`. Every applicable summary includes its counter. Sequential lifecycle stages remain visible on each request card and are not duplicated in a page-level Timeline filter. Declined and Cancelled remain represented by the `Needs Attention` summary rather than separate filter buttons. -- Requests, Issues, and Blocklist separate media type into a dedicated `Media Filters` section between Task Filters and regular Filters. The media buttons retain the same order and styling across those pages. Media-level pages use one `Books` filter because those records do not distinguish book and audiobook requests; Request Status may show separate `Books` and `Audiobooks` controls because its records retain the requested format. -- The regular `Filters` section begins with Time Period and Keyword Search in one naturally wrapping row. Page-specific regular filters precede those shared controls when applicable. The Issues page keeps its compact `Issue Type` dropdown with `Any`, `Audio`, `Video`, `Subtitle`, and `Other` at the end of Task Filters; only the label segment receives the active highlight when a specific type is selected. -- Count bubbles show the number of records matching each task summary within the currently selected regular filters, without applying the selected task-state filter itself. -- Request managers open Request Status on `All Users` so approval-required requests submitted on another user's behalf remain visible. Selecting a specific user narrows the page explicitly; users without cross-user permission remain scoped to themselves. - -Use `CompactSelect`, `CompactRatingSelect`, `getFilterResetButtonClass`, and `getFilterToggleButtonClass` from `src/components/Discover/FilterPanel/CompactFilterSelect.tsx` for these controls. - -## Buttons - -- Use the shared `Button` or `ButtonWithDropdown` components. -- Compact page actions use the small button size, consistent icon sizing, a one-pixel border, and the standard focus ring. -- Button colors derive from one repeatable palette recipe: the border uses the normal/base palette hue; the resting background keeps that hue and saturation at 10-percent lightness and 55-percent opacity; hover uses 20-percent lightness and 70-percent opacity with white text; active uses 20-percent lightness and 85-percent opacity and returns to the palette tint. Focus uses the base hue for the ring. Preserve this relationship when adding a hue. -- Destructive or cancel actions use `app-button-danger`; Blocklist uses `app-button-blocklist`. Both use the red palette. -- A request-edit modal's `Close` action is a destructive-style exit control and uses the same shared red danger treatment on Movie, Series, Music, and Book forms. -- Successful submission or approval actions use `app-button-success`; bulk creator requests use `app-button-bulk-request`; quality-aware detail requests use `app-button-detail-request`. They use the green palette. -- Warning actions use `app-button-warning`; Report Issue uses `app-button-report-issue`. They use the yellow palette. -- Manage uses `app-button-manage` and the purple palette. Cast, Crew, View Artists, Genres, and Subject Tags disclosures use `detail-disclosure-control` and the plum palette. -- Associations uses `app-button-association` and aqua. Search Prowlarr uses `app-button-prowlarr` and blue. Watch Trailer uses `app-button-trailer` and orange. Media-server and device playback use `app-button-playback`; that class keeps standard page-button geometry but copies the poster media-server status control's pure-black 70-percent surface, gray resting border, and white content. Hover brightens the border to gray-200, while focus and active use gray-100, without changing the black surface opacity. The Visual Lab Buttons page's normal Black Palette row mirrors this exception; the Title and Border pages retain the established black solid, gradiant, and metalic palette treatments. -- Neutral secondary actions use the default or ghost treatment. -- Tooltips explain icon-only buttons and use the shared tooltip component. -- Media-type application buttons use explicit complete classes: `app-button-media-type-movie`, `app-button-media-type-tv`, `app-button-media-type-collection`, `app-button-media-type-album`, `app-button-media-type-artist`, `app-button-media-type-book`, `app-button-media-type-comic`, and `app-button-media-type-magazine`. Runtime media types must map to these complete strings; do not build `app-button-media-type-*` fragments dynamically. - -## Selection Circles and Availability Icons - -- Every season, episode, music track, audiobook track, and collection-item selection circle uses `src/components/Common/SelectionCircle/index.tsx`. Its complete geometry and palette live in the global `.selection-circle` and `.selection-circle-icon` rules in `src/styles/globals.css`; consumers may not add local sizing, color, fill, or positioning classes. -- The inactive selector is a fixed 16-pixel circle with the established dark gray-blue fill and border. The fully selected state uses the same geometry with the established bright emerald-green fill and border plus the simple white solid `CheckIcon`. A season row whose episode subset is selected uses the same white check with a dark emerald-green fill and border to communicate partial selection. A select-all heading remains inactive when only some child rows are selected; the partial season state does not introduce a different icon. -- Disabled selection circles remain visible, use the same geometry, reduce opacity, and show the prohibited cursor. They do not gain hover-state color changes. -- The outlined `CheckCircleIcon` and `XCircleIcon` are availability/status symbols only. They may appear in the availability column, but neither may be used as an interactive selection control. -- The shared selector applies to the Request/Report-an-Issue season and episode selector, the Series Details playback selector, Music Details track selectors, Audiobook Details track selectors, and Collection Details item selectors. - -## Badges - -- Media-type and status badges shown on the same row must have matching visual height, border thickness, and vertical alignment. -- Status or source badges rendered inside a compact detail row use the Issues-page compact badge as the standard: a 16-pixel outer height, 8-pixel text, one-pixel border, and four-pixel horizontal padding. They must fit within the row's normal line height and must not increase the card or poster-aligned detail height. Badges representing alternatives in the same field use the same component and exact dimensions; verify ascenders and descenders with glyphs such as `Y` and `y`. A 14-pixel internal line height may be used when required to preserve those glyphs without changing the outer height. -- Issue `Open` and `Resolved` badges and Blocklist source badges reuse the same global compact-detail status geometry and dark translucent surface. Their semantic border and text colors may differ, but their height, padding, font size, line height, and border weight may not. -- Detail values that have a valid in-app destination remain clickable on every card using the shared details-and-values layout. This includes people such as directors, creators, artists, and authors; movie studios; series networks; and displayed genres or subjects. Use the standard indigo link color, underline on hover, and visible keyboard focus treatment. Plain facts without a destination, such as runtime, dates, counts, descriptions, and publishers, remain text. -- Badge icons and text are vertically centered. Availability headings and every green-check or red-X value use the same shared centered cell, with header padding matching the scrollable rows so the icons remain on one vertical axis. -- Media-type colors remain specific to the media type; status colors continue to communicate state. -- Detail-page badges use the same shared outer height as other badges while preserving their semantic border, background opacity, icon, and text treatment. -- Discover home title posters use a wider 2:3 card: 192 pixels below the desktop breakpoint and 224 pixels from the desktop breakpoint upward. Discover poster shelves reserve 320 and 360 pixels respectively, including 12 pixels of vertical padding, so the complete border remains visible during the five-percent hover expansion. Keep this sizing scoped to Discover so its two-column badge rows can show complete media-type labels without changing library grids or detail-page recommendation shelves. -- Shared status, media-type, and format badges retain their established 35-percent resting background opacity. Interactive linked badges may rise to 55-percent opacity on hover, but non-interactive badges do not become opaque. Standard semantic buttons are intentionally more solid at 55-percent normal, 70-percent hover, and 85-percent active opacity for reliable readability over artwork and textured cards. Preserve each badge's established semantic hue, border, text, icon, geometry, and tooltip while keeping badges separate from the button opacity contract. -- Poster controls are the explicit opacity exception: every media-type, availability, processing, pending, Association, Blocklist, watched-state, and format control over poster artwork uses `poster-control` plus its complete `poster-control-*` role class and `--poster-control-background-opacity: 0.7`. This is 70-percent opaque and 30-percent transparent. Never attach an `app-button*` or `app-filter-button` class to a poster control. -- Poster pending-approval bell badges remain yellow and processing timer badges use the blue palette, but both use the shared poster surface so artwork remains visible behind them. Their tooltips name both the quality and the state. The Blocklist action retains its established no-request/no-media visibility rule and occupies the first-row right status slot whenever that slot has no quality badge; it does not create a third poster row. - -## Cards and Layout - -- Browse and shelf poster roles use the finalized two-pixel solid blue frame. Embedded collection and detail posters use it at one pixel. Main translucent content cards use two pixels; subcards, inset cards, and Manage Media section cards use one pixel. The shared frame is owned by `--app-card-frame-background: var(--palette-blue, #0051d4)` so the approved base blue remains consistent and a user-selected palette override can replace it without introducing a gradient. Opaque posters use a transparent border with layered padding-box and border-box backgrounds. Translucent cards use the shared perimeter mask so the blue cannot tint the card body; do not substitute a full-card border-box fill or page-local border. -- The application canvas uses one full-viewport four-color treatment. A narrow radial light-purple spotlight stays confined to the upper-right corner; beneath it, a 40-degree diagonal gradient moves through the primary blue, dark blue, and black at the lower-left edge. The light purple is a highlight rather than a dominant page color. Do not confine the treatment to a short header band. Apply the same treatment to narrow-window navigation slide-outs. -- The light appearance changes the canvas, cards, controls, and text together. Do not combine light-mode gray text with dark-mode page or control surfaces. Sidebars, artwork, and other surfaces with white foreground text retain their dedicated dark palette tokens in either mode; do not derive their overlays from the mode-switched gray scale. -- Refreshed media cards use rounded corners, the shared solid blue frame, a translucent primary-blue surface fill, and a subtle backdrop blur. Inset cards use the shared translucent blue control surface. Both surfaces inherit the shared blue-lavender content tone; intentionally muted copy uses its softer blue-lavender companion rather than neutral gray. Headings and labels use high-contrast white text in dark mode and high-contrast dark text in light mode; links retain the brighter shared indigo treatment. Do not replace either surface or its content text with neutral gray or near-black. These shared blue translucency and text levels apply to Request Status, Issues, Blocklist, request forms, issue details, media details, and new cards built from this design. -- Every centered modal card uses the single shared black screen backdrop at 80-percent opacity. Do not tint the page behind Create Issue, Manage, Associations, request forms, confirmations, or any other shared modal blue; the darker neutral layer must recede so the foreground artwork card remains visually dominant. -- Components may use local utility classes for structural geometry and responsive layout, but reusable size, spacing, color, surface, border, effect, and scrollbar treatment must come from shared semantic classes in `src/styles/globals.css`. `pnpm ui-style:check` scans every component in the application and rejects embedded style blocks, visual inline-style properties, pseudo-element column dividers, the larger legacy `default` action-size token, neutral gray secondary text inside shared cards, and nested near-black card surfaces. Runtime data-driven values such as progress width or a live Theme Picker color swatch may remain inline only when a static semantic CSS class cannot represent them. Every newly confirmed site-wide defect must become a shared rule plus a validator rejection of the obsolete pattern before the next build. -- Full-card artwork uses the shared 46-percent primary-blue readability wash plus the horizontal 18/32/58-percent light-to-deep blue gradient. Do not add a page-specific extra scrim; all artwork cards must retain comparable blue color visibility while keeping text readable. -- Artwork-backed detail cards continue expanding their `cover` crop with the card when disclosures open. Firefox renders that same cached artwork URL through the shared CSS background path, guarded by a Mozilla-only feature query, to prevent repeated disclosure toggles from accumulating image zoom. Other browsers retain the standard image paint path. -- Full-page and modal artwork use one uniform readability scrim. Do not fade artwork vertically into a solid page color at its lower edge; page artwork should retain consistent color from top to bottom. -- Movie, Music, and Book request artwork belongs inside their main request content card rather than across the surrounding site-gradient modal. Request Series instead uses the Report Issue pattern, where the bordered main modal card itself owns the artwork and every inset section. In both constructions, use the largest provider image available, make it fill the complete owning card with `object-cover`, clip it to that card boundary, and retain the shared dark overlay for readable content. Reusable artwork gradients, scrims, shadows, and filters belong in the shared stylesheet rather than an inline style or page-local copy. -- Related detail columns use consistent gaps and aligned dividers. Avoid nested layout structures that create hidden or uneven spacing. -- Expandable cards open in the page flow without shifting the parent card sideways or extending beyond the reachable scroll area. -- Button rows use the same inset on the left, right, and bottom as the surrounding card. -- Use a five-pixel vertical gap between adjacent card regions, including the poster/detail region, timeline or expandable region, and footer action row. Do not add a second padding value that doubles this gap. -- Treat 720 CSS pixels as the desktop-card breakpoint so two side-by-side browser windows on a scaled FHD display retain side-by-side detail groups. -- Card detail groups size each heading track to that group's longest heading, then use the same three-quarter-rem gap between headings and values that appears between a vertical divider and the following group. The first media-detail value column remains six rems wide in the desktop-card layout so the one-third divider positions stay fixed; oversized values truncate instead of shifting the grid. -- Paired quality or format availability values use one shared semantic component on every media detail card. `Available` and `Partially Available` are green; `Processing`, `Pending`, and requested in-progress formats are yellow; unavailable, unknown, and blocked values are red. Movie and Series apply the rule to HD and 4K, Music to MP3 and FLAC, and Books to Book and Audiobook. -- Timeline scroll controls remain hidden while the complete timeline fits. Show them only when measured horizontal overflow actually clips part of the timeline. -- Issue detail pages begin with the same compact poster-and-details card used by request pages. The right detail group uses `Created By`, `Created On`, an unlabeled time row, and `Issue Type`, in that order. -- Report an Issue forms use one clipped Collection-style outer card with full-card media artwork and the shared readability layers. The media summary, affected-season and episode selector, and description are darker inset subcards inside it. Do not repeat the media title beneath the page heading. Movie and Series forms place content-width `Quality` and `Issue Type` dropdowns in the media-summary footer; `Issue Type` defaults to `Other` and replaces the former radio-button grid. Series forms keep the season-and-episode selector immediately below that summary. -- Movie and Series issue cards always show the saved target in `Media & Format` as `Movie · HD`, `Movie · 4K`, `Series · HD`, or `Series · 4K`. The Report an Issue `Quality` dropdown lists only the HD and/or 4K copies currently marked available, including partially available Series; never invent an HD fallback when no quality is available. -- Report an Issue description entry uses the same inset-card and three-row, vertically scrollable textarea treatment as the Issue Details comment entry. `What's Wrong?` and the textarea are the only normal contents of that card; validation feedback may appear when required. Shared-height red `Cancel` and green `Submit Issue` actions sit in a separate right-aligned row directly below it. Both actions preserve the standard six-pixel gap between their leading icon and label. Red and green modal confirmation actions, including Continue and equivalent submit actions, default to this same shared size site-wide. -- Issue description, affected-episode, and comment regions use the Request Status history-card treatment and a five-pixel gap between adjacent cards. -- Series issues persist the selected quality and a structured list of every selected season and its selected episodes. The issue detail page reconstructs that list into `Season`, `Episode`, and `Title` columns with one vertical scrollbar after eight visible rows. Legacy single-season and single-episode issues remain readable through their original fields. -- Create Series Issue and Request Series reuse the same two adjacent selection cards after the media details card: a content-width season list with episode counts and a flexible episode list with episode number and title. Both heading rows own one select-all circle. A season selection may represent the entire season or a saved subset of episodes, multiple seasons retain independent episode subsets, and selected circles use the timeline's green fill and white checkmark. -- Exact series request selections persist with the request and are sent to Sonarr as episode monitoring plus an exact episode search. Omitting an episode list preserves the established whole-season request behavior. -- Description rows show the absolute date and time on one line with a visible separator space, followed by the description in the same row without reserved blank rows or vertical centering. -- Comment rows show the absolute date and time on one line with a visible separator space, followed by a dedicated avatar column spanning the visual height of the username and first comment line. The username occupies the first content row and the comment begins immediately below it with compact line spacing. Edit and delete actions remain top-aligned at the far right; only the first comment line reserves their width, while subsequent wrapped lines use the full content width. On Issue Details, comment entry is a full-width three-row textarea directly beneath the comments without an additional wrapper card and scrolls vertically beyond three rows. -- Issue action rows place the translucent yellow Add Comment action at the far left, followed only by eligible automation-service links; Issue Details never shows a Play on Plex, Jellyfin, or Emby action. A translucent red Cancel action sits immediately to the left of the translucent green Close Issue or Reopen Issue action at the far-right edge. Every control in this row uses the standard shared button size rather than the smaller disclosure-button size. The saved HD or 4K issue target controls which automation-service link opens. -- Absolute audit timestamps show the date before the time. This applies to issue descriptions, issue comments, and Request Status history entries. -- Request cards show approval state in the right details group: compact green `Approved Automatically` when the selected requester has the applicable auto-approval permission, or compact yellow `Approval Required` otherwise. Remove duplicate approval text beside `Advanced Options`. This state and the submitted request status always follow the selected requester's permissions, even when an administrator acts on that user's behalf; do not use a separate alert card for this message. -- Every action that adds an item to the Blocklist must first open the shared confirmation dialog. Dim the complete page behind a centered card that is 18rem wide and at least 9rem high on desktop (twice the width and height of the compact Discover genre card, capped to the narrow viewport). The card uses the complete website background gradient and contains only the bold, centered sentence `Are you sure you want to blocklist this item?` followed by the horizontally centered standard red `Cancel` and green `Blocklist` buttons. Do not add artwork, an item title, a media label, explanatory copy, or page-specific variants. Removing an existing item from the Blocklist remains a separate action and is not covered by this add-confirmation rule. -- Request availability follows the currently selected destination quality or format, including changes made in Advanced Options. When that exact selection is already in the library, replace the approval-state text with the shared compact green `Available` badge and disable the request action. Enforce the same condition in request admission so a stale or manually submitted form cannot create the duplicate. A combined Book + Audiobook request remains eligible until both formats are available. -- Fresh Movie and Series request forms expose every configured destination quality the requester is permitted to use, regardless of whether the form was opened from a title card or a detail-page action. Selecting a destination refreshes its current quality profiles, root folders, languages, and tags. Music and Book request forms likewise populate service, metadata-profile, quality-profile, and format choices from the currently configured service rather than a static list. -- A details-page availability badge does not hide the request entry point when another configured destination may still be eligible. For albums, keep `Request` visible to permitted users unless the item is blocklisted; the request card determines availability from the selected Lidarr instance so an existing MP3 copy does not prevent a FLAC request, or vice versa. -- Media requests are admitted quickly and dispatched to the selected automation service in the background. A request-in-progress indicator may remain visible, but it must not intercept poster links, prevent opening media details, or block requesting a different uncovered quality. Explicit service IDs remain valid when their numeric value is zero; never replace a zero-valued MP3 or FLAC destination with the default service through a truthiness fallback. -- Music Details does not repeat quality or overall availability badges beneath the album title. The third compact details group always lists `MP3` on row one and `FLAC` on row two, using green `Available` or yellow `Not available` values derived from configured profile metadata and saved Lidarr destination availability rather than the album title. Its bottom-right `Select Quality` control chooses the matching Lidarr recording-file availability and media-server catalog used by track availability, selection, server playback, device playback, and playlist replacement. -- The permission-controlled `Requested By` control lists every Seerr user for request managers, including users who do not independently hold the selected media-tier request permission. A manager may create the request on that user's behalf, while approval state and quota accounting continue to follow the selected user. Use the same compact label and selected-value text sizing on Movie, Series, Music, and Book request cards. Size it to the longest available username so changing the selection never resizes the control. Its label, selected user, and chevron remain vertically centered within the fixed-height control; portal hosts use a flex container so inline baseline spacing cannot shift the control or its text upward. -- Fresh Movie, Series, Music, and Book request forms open Advanced Options by default. Collection, bulk, and edit-request Advanced Options remain continuously open. The Advanced Options control, Requested By control, and media-detail Cast, Crew, and Subject Tags controls use the same shared blue control surface and border as the Destination Server dropdown; their height follows the shared control rule. Destination Server, Quality Profile, and Root Folder use the one shared request-listbox component and global semantic classes: every menu has the same dark-blue surface, indigo hover/active row, and visible checkmark on its selected option. Do not replace Root Folder with a native select or page-local dropdown styling. -- Full-size Movie, Music, Book, Collection, and bulk request modal surfaces use the complete site background gradient instead of a solid fill. Request Series follows the Report Issue construction: one shared bordered main modal card owns the full artwork, scrim, and gradient; its media summary, season/episode selector, advanced settings, and actions are direct main-card content or standard inset subcards rather than children of a second artwork card. Report Issue and Request Series use the same shared main-card class, and the full modal viewport owns vertical scrolling rather than placing a scrollbar inside that main card. -- The Music `Import Playlist` dialog uses that same complete site background as a centered, readable-width rounded and bordered card with the standard shadow and enough inset width to leave the page background visible on all four sides. Its URL input uses the translucent request-control surface; Cancel is red, Preview Matches is green, Connect or Reconnect Spotify uses the cyan Associations treatment, and Spotify and YouTube guidance sits in the shared darker inset surface rather than unstructured text on a solid modal. -- Advanced request root-folder tables use only two content-sized columns: `Root Folder` followed immediately by `Available Space`, separated by the standard three-quarter-rem detail gap. The root-folder column follows its longest visible path while remaining shrinkable in narrow layouts. Do not show a separate default-name or unlabeled metadata column. Keep five or fewer rows fully visible; when more than five root folders exist, constrain and vertically scroll the data rows while leaving the headings visible. -- Request-card table rules and compact details-group dividers are two pixels wide and use the shared blue control-border color at 72-percent opacity. - -### Media Detail Pages - -- Movie Details establishes the shared detail-page structure for the later Series, Music, and Books refreshes. Put the largest available backdrop inside one clipped main card and fill it with `object-cover`; do not repeat the poster, media badge, status badge, giant hero title, or hero metadata strip. -- Begin the main card with the standard compact three-group details layout and keep its request, issue, blocklist, watchlist, associations, trailer where available, management, and media-server playback actions functional. -- Secondary detail values and descriptive text use the shared palette-aware blue/lavender content tone. Do not replace that semantic detail text with neutral gray; links retain the brighter shared indigo treatment. -- Movie and Series Details place eligible media-server and device playback controls first in the primary action row, immediately followed by Blocklist, Manage, Report an Issue, Watch Trailer where available, Associations, and the local-user watchlist utility. A separate non-wrapping request row follows: Search Prowlarr is left-aligned and the green Request control is right-aligned. All controls use the shared compact treatment. -- The Overview inset card uses this order: heading, italic tagline or quote line, one blank-line gap, overview text, and six featured crew facts arranged as three divided detail groups. People names remain linked. -- Movie and Series keep quality selection and every rating pair in one unboxed, non-wrapping rating row above the action rows. Playback controls no longer compete for that width: `Play on ` and `Play on Device` begin the following primary action row, immediately before Blocklist. Each rating image/value pair uses the shared five-pixel internal gap; Metacritic and Trakt use that exact same logo-to-value spacing. The visible vertical space between rows is exactly the shared five-pixel card gap. Where a track, episode, or chapter selector exists, the rating row immediately follows that selector so the selection's effect remains clear. Both playback labels are vertically centered, use the same visible gap between logo and text, and render the provider logo at the full text height while preserving its intrinsic aspect ratio. Both actions consume the same selection-circle state in canonical content order; an empty selection means every available item. `Play on Device` creates only a temporary playback queue. `Play on ` replaces the signed-in server user's single persistent `SeerrNG - Current Selection` playlist and opens it; it must not accumulate multiple SeerrNG playlists. Movie and Series retain their compact `Quality` control in the rating row. Default to the lower quality whenever it is available or neither quality is available; choose 4K only when it is the sole available choice. Both playback actions and playlist replacement use only the selected quality and never mix separately cataloged quality roots. Each rating source icon is optically normalized to the tomato height and its value is centered beside it; icons, wordmarks, and values use a subtle black shadow for artwork readability without adding a containing box. -- Every multi-column details group uses equal, two-pixel vertical dividers in the shared blue control-border treatment at 72-percent opacity on desktop widths. Each divider is the ordinary left border of the second or third detail column. Do not insert a divider element and do not draw one with a pseudo-element. All details columns reuse the same shared border class, color, width, padding, and responsive behavior. Mobile-only horizontal separators disappear when the multi-column desktop layout becomes active. Horizontal rules are reserved for track, chapter, season, and episode table headings, where they also use the shared blue control-border color at exactly two pixels. -- Issue list cards follow that same three-group detail layout: both separators are ordinary `media-detail-column-divider` borders on the second and third groups. An Issue card may not reserve a one-pixel grid track, insert a gray divider element, or locally choose a different divider color. -- Blocklist cards use the identical three-group divider construction as Issue cards, including two shared owning-column borders and no standalone divider track or element. The Affected Episodes heading rule on Issue Details uses the shared two-pixel dark-blue horizontal table divider rather than a local one-pixel gray border. -- The expandable control row uses the shared control height and retains its 11-pixel text and secondary visual treatment. Use compact history-style dropdown buttons in this order: `View Cast`, `View Crew`, `Subject Tags`. Give this row exactly the shared five-pixel vertical card gap above and below. Cast and crew panels show three inset person cards per row and three rows within a vertically scrollable region. Each card uses the full card height for the profile image and links the whole card to the person page. Missing profile artwork uses the shared corner-free `camera-shy-profile-placeholder.png` asset. -- Cast, Crew, View Artists, and Subject Tags controls expose an independently selectable pushpin segment to the left of the label where that disclosure exists, separated by a vertical divider. Use the conventional angled menu pushpin rather than a map-location pin. The selected pushpin uses the solid icon and `aria-pressed`; the unselected pushpin uses the outline icon. Selecting a pin immediately opens that disclosure for the signed-in user only within its own media category; Movie, Series, Music, and Book pin states never change one another. Clearing a pin immediately collapses its category-specific disclosure. The main button body still provides ordinary per-page expand/collapse while unpinned. Store these category-scoped preferences in the user settings database so they survive navigation, devices, and later logins. Collections consume the Movie category. View Artists and Subject Tags remain independent within Music Details; Book Genres uses the Book category's Subject Tags slot because it is the equivalent metadata disclosure. -- The history-style disclosure buttons—`View Cast`, `View Crew`, `Subject Tags`, `View Artists`, and `Genres`—use the shared control height through `DetailDisclosureButton`. The quality dropdown uses that same height and remains content-width; its Movie label is `Quality`, while the other detail pages retain `Select Quality`. Badges and adjacent controls also use the shared height; pages may not hard-code a local size. The green quality treatment remains distinct from the darker disclosure-control surface. All `refreshed-inset-surface` subcards use the darker 42-percent control-surface opacity; outer `refreshed-card-surface` cards retain their existing opacity. -- Subject tags use compact, deterministic varied-color badges. Every badge links to movie discovery filtered by that subject tag. -- The `Movie Details` inset card uses three equal-width groups separated at the one-third marks. Group one contains Status and up to three stacked Release Dates; group two contains Revenue, Budget, linked Language, and linked Country; group three contains no more than four linked Studios. -- Recommendations and Similar Titles remain outside the main card. Their heading links omit the circular arrow, and the title-visibility control sits one compact gap from the heading. Each linked full-list page reuses the exact shared Movie or Series discovery filter and sort component for the media it displays; do not copy or independently restyle those controls. -- Inset-card headings use the shared 14-pixel, semibold, white `media-inset-heading` treatment. Column headings inside inset tables use the shared 12-pixel, semibold, white `media-inset-table-heading` treatment. Do not substitute muted body-copy blue for either heading role. -- Series Details follows the same artwork-backed card, Overview, separated rating, primary-action, and request-action rows, expandable Cast/Crew/Subject Tags, linked fact groups, and external Recommendations/Similar Series structure as Movie Details. Rename facts for series semantics: Creator, Network, Series Type, First/Last/Next Air Dates, and Episode Runtime. -- Series ratings preserve direct Rotten Tomatoes and TMDB values and may add explicit source ratings from the administrator-configured MDBList API. IMDb values are only shown when MDBList identifies the rating source as `imdb`; never infer IMDb from Sonarr's generic Series rating or scrape IMDb pages. MDBList failures leave other rating sources visible. -- Directly below the Series compact details grid, reuse the two-card Series request selector proportions as a playback selector. Both headers include the shared select-all circle. The content-width left card left-aligns Season and centers episode count and availability; the flexible right card left-aligns Episode and Title and centers availability. Every availability header and row cell stretches across the same grid track and centers its icon, so row icons align exactly beneath the header icon. Selecting a season selects every playable episode in that season, multiple seasons and episodes may be selected, and unavailable items remain visible but disabled. Playback remains in season-and-episode order regardless of selection order. The header availability icon alone owns the legend tooltip; individual rows do not repeat it. -- Music Details follows the same artwork-backed main card, compact three-group details grid, ordered action rows, expandable controls, final details card, and external related-title slider where music metadata has a reliable equivalent. Use Artist, Album Type, Track Count, MP3/FLAC destination availability, View Artists, Subject Tags, and Album Details terminology. Omit the unused Artist Overview panel and unreliable total-listen statistics; do not manufacture movie-style ratings or crew roles when the music providers do not supply them. -- Directly below the Music compact details grid, present the ordered track list as two adjacent playback-selector cards without a separate full-width Album header card. Split the ordered tracks evenly, retain left-aligned `Track` and `Title`, center `Runtime` and availability, and cap the region at 214 pixels with one vertical scrollbar. Only the left card heading has a select-all circle; it selects every playable track across both cards. Each row's green check or red X follows the selected Lidarr instance's recording-level file state, with the selected media-server catalog as a fallback when Lidarr cannot be read. Row selection remains disabled without a matching playable media-server item. Playback follows original disc-and-track order regardless of selection order, and changing `Select Quality` switches the table and actions between the exact MP3 and FLAC provider roots. The Genres value in the compact details grid spans both metadata columns so long genre lists can use the remaining width before the availability group. -- Music Details follows the shared primary action order, followed by its dark-green `Request Discography`, any active `View Request`, and the green quality-aware `Request` action. The local-user watchlist utility remains secondary. Its playback/rating row uses the shared server and device controls and shows MusicBrainz rating data, falling back to the configured Lidarr album rating when MusicBrainz has none. -- Books Details follows the same artwork-backed main card, compact three-group details grid, ordered action rows, Overview card, expandable control row, and final details card where Open Library supplies dependable values. Use Author, Publisher, First Published, Pages, Editions, ISBN, and separate Book/Audiobook availability terminology. -- The Book summary header uses three primary metadata rows followed by one Genres row, matching the compact height of the other detail-page headers. Publisher remains in the lower Book Details card. Do not render the empty audiobook-selector message above disabled playback controls; render the selector only when its catalog contains playable tracks. -- Present Open Library subjects to users as `Genres`. Put the complete set in its own expandable Genres card, use the standard compact varied-color badges, and link every badge to Books discovery with that underlying Open Library subject selected. Do not leave a second always-visible Subjects badge region on the page. -- Books Details follows the shared primary action order, followed by its dark-green `Request Bibliography`, an active format-specific `View Request`, and green format-specific `Request Book` and `Request Audiobook` actions. The local-user watchlist utility remains secondary. Preserve the existing Open Issues region within the main card when issues exist. Audiobook playback uses the same selectable track interface as Music and lists playable tracks rather than chapters; the table contracts to its actual short content. Do not add cast, crew, trailer, or related-title rows without dependable provider data and valid action targets. -- Request Status History uses four explicit columns in this order: Date, Time, Action, Description. Never combine date and time in one stacked column or allow the action text to overlap either timestamp. Its History or Hide History trigger uses the same compact rounded geometry as the neighboring request actions with the shared purple Manage treatment. The complete request-card action row is fully justified: keep the status control at the left edge and History at the right edge. When request actions wrap, every continuation line remains anchored to the right edge. - -### Discover Page - -- Discover shelf controls use the shared button height. Title visibility uses the default button and the selected primary treatment, randomized Refresh uses the orange treatment, and Previous and Next use green. Icon-only controls remain square with accessible labels and tooltips. -- Discover filter and sort actions use the same shared control height. React Select controls embedded in the filter row match the Runtime dropdown's vertical alignment, dark menu, indigo hover and selected state, and selected check mark. Multi-select menus keep already chosen filters visible so their check marks remain discoverable. Both their control and selected-value pills use the shared dark translucent surface; provider defaults may not reintroduce a solid white selection background. -- Genres is a single-value filter on Movie, Series, Music, and Book discovery. It uses the same shared `CompactSelect` behavior as the neighboring dropdowns: `Any` is the neutral option, the selected row has the standard checkmark, and choosing a genre replaces the previous genre instead of adding a removable multi-select pill. -- Studio and Network shelf buttons display an original-resolution color PNG when one is available. Black-only source marks use a white fallback treatment, never a black presentation, and the linked result page repeats the same curated treatment. The page keeps the company name as a normal top-left heading and reuses the complete Movie or Series filter and sort interface with that Studio or Network locked as the originating filter. -- Discover Recent Request cards and their loading placeholders use content-height compact geometry and artwork-matched frames. The compact slider track contracts to that content instead of reserving a full poster shelf. They omit inline approval and decline controls because those actions belong in the dedicated request-management workflow. -- Discover Recent Request cards omit the Season label, season number, Status label, and status pill. Availability and quality controls sit beside one another in one row, and the request poster stretches to the height of the card's text-and-control content without a fixed square or 2:3 aspect-ratio utility. -- Discover category headings do not show the circular arrow-link icon. Keep exactly one title-visibility control, position it one compact gap from its heading, and remove duplicate visibility controls from the same category row. -- Directly before the Discover content shelves, show a `Media Filters` heading with standard compact Movies, Series, Music, Books, and Audiobooks buttons in that order. Each button opens the corresponding discovery page, whose own shared Filters and Sort By controls govern the selected media type. -- The linked Trending page retains that same five-choice `Media Filters` row. Selecting Movies, Series, Music, Books, or Audiobooks swaps in the complete filters and sort controls belonging to that media type; Trending must not fall back to the legacy native media/time dropdowns. -- Use the vertical distance between the Request Cards region and the following Trending heading as the standard category-to-category gap throughout Discover. Do not allow individual sliders or category wrappers to introduce oversized blank regions. -- Every Discover badge and button uses the shared compact treatments from this standard; do not retain page-local oversized or legacy variants. -- Genre, Studio, and Network category cards use half the current legacy footprint while preserving readable labels, keyboard focus, and their complete clickable target. -- Keyword Search updates after a short typing pause on Movies, Series, Music, and Books; submitting the field remains a supported immediate action. The header search activity indicator stays active while the resulting provider request is loading. It occupies the reserved top margin above the page title and must never overlap the title; its shared containing region prevents the title's top margin from collapsing into the indicator's positioning context. -- A successful but empty provider response to the default broad all-books feed is treated as a provider failure and displays the explicit provider-error state. It must not be presented as a genuine empty catalogue or ordinary `No results` response. - -### Media Detail Actions - -- Movie, Series, Music, Book, and Collection detail action rows begin with eligible Blocklist, icon-only Manage, and icon-only Report an Issue controls in that order. Movie and Series insert their orange Watch Trailer control immediately before Associations. Use one full-width, wrapping, `space-between` action row with no auto-margin spacer so every visible action is evenly distributed when permissions or title capability remove an item. -- Shared button geometry owns the space between an icon and its label. Action labels must not add a second local left margin; Watch Trailer, Associations, Request, and every other icon-label action therefore retain one consistent gap. -- The media Associations dialog uses the site background inside its readable-width main card and responsive inset media-detail cards. It has no top-right close icon. Place a translucent red Cancel action at the bottom left of a translucent green Browse More action, with Browse More aligned at the bottom right and leading to the full Associations explorer. -- Every Association result reuses the complete artwork-backed Issue detail-card block and occupies one full-width row; never place two result cards beside each other. Each card paints that title's own backdrop or applicable album, artist, or book artwork beneath the shared scrim and gradient, then uses the standard 80-by-120-pixel poster, linked title, compact details groups, and dividers. Do not show a redundant Status heading or value. Its right column contains the HD and 4K availability rows for Movie and Series, or MP3 and FLAC for Music, followed by one blank row and relationship text that may wrap across two lines. The full Associations list explorer and popup both reuse this single shared card. Selecting either the poster or linked title from an Associations popup closes the popup as navigation begins; do not rely on page unmounting to clear modal state. Keep the alternate graph view available at supported widths. -- Icon-only Manage and Report an Issue controls keep their labels in accessible tooltips and `aria-label` text. Blocklist uses the poster-card red treatment, Manage uses purple, Report an Issue uses a distinct true-yellow treatment, Associations uses the cyan/teal/blue subject-tag treatment, and bulk creator requests use a darker green than individual Request controls. -- Manage Movie, Manage Series, Manage Music, and the shared Book manager open in a centered, readable-width artwork-backed main card that matches Report an Issue and Collection Details instead of a narrow side panel. When open issues exist and the user may view them, begin with the exact full artwork-backed Issue list card for each open issue and do not render a second generic media summary or simplified Open Issues subcard. When no viewable open issue exists, retain one embedded poster-and-three-column media summary so the manager still identifies the item without duplication. Movie and Series summaries show HD and 4K availability; Music summaries show MP3 and FLAC, followed by request and open-issue counts. Book retains its applicable status and counts because it does not use the video/audio quality pair. Put every remaining management section in a darker inset subcard with the standard five-pixel gap. Do not create separate Media or 4K Media cards: service, Tautulli, remove-from-service, availability, and Clear Data controls all belong inside the single Advanced card. Every action uses the shared `standard` size token, which resolves to the shared control height, 12-pixel text, established icon size and spacing, and content width; the component's implicit default resolves to this same standard. A manager must never stretch an action across the card. Place the translucent red Cancel action at the bottom right exactly five pixels beneath the final card. Preserve all service links and destructive confirmations. -- All ordinary action buttons, including Movie, Series, Music, and Book detail actions, Issue Details actions, Settings actions, modal actions, management actions, and both halves of split Request controls, use the shared control height and 12-pixel text. The `standard`, `md`, and `sm` component aliases may express intent but may not create competing visual heights. Filters, sort controls, disclosures, and quality controls follow the same shared height; do not add page-specific overrides. -- Disabled playback dropdowns use the same cursor, opacity, brightness, and grayscale treatment as disabled playback buttons so unavailable server and device actions are visually equivalent. -- Movie and Series rating rows use the same global geometry: every rating image is 20 pixels high, each value is vertically centered beside its image, and every image/value pair shares the same 30-pixel row alignment. -- Rating source icons, wordmarks, and values use a clearly visible black edge shadow so the group remains legible over light artwork without adding a box. -- Metacritic uses `media-rating-brand-metacritic` with `#ffcc33` behind `#171717` content. Poster-hover rating details use the shared `poster-rating-popover`; the popup does not display a redundant `Ratings` heading. Keep every available rating on one non-wrapping horizontal line. Movie and Series slider cards pass both the TMDB score and vote count so the popup can show TMDB, Rotten Tomatoes critic, Rotten Tomatoes audience, and IMDb when those values are available. Position the popup from its measured rendered size: prefer below the poster when the panel actually fits, otherwise place it above without clipping or estimating a fixed panel height. -- Colored detail-page actions use a translucent dark tint, colored border, and matching colored text. They do not use a solid bright background with white text. -- Every button except a filter or sort control applies the same black readability shadow to both its label and icon. The shadow remains present in normal, hover, active, focus, and disabled states. Shared Button links, dropdown triggers, raw button elements, icon-only actions, and selection circles all inherit this rule centrally. Discovery filters, sort buttons, filter dropdown triggers, and segmented filter options remain explicitly shadow-free. -- Shared primary, warning, danger, and success buttons use the same translucent dark tint, colored border, and matching text site-wide. Solid indigo, yellow, red, or green fills are not the standard idle state. Enforce these variants centrally so every page using the shared Button component inherits the standard without page-by-page restyling. -- All detail actions use the shared small control dimensions. Request and Request in 4K controls and device-play dropdown triggers must not grow taller than neighboring controls. -- Media detail pages do not expose separate Approve Request or Decline Request buttons. View Request remains available, and a permitted normal request action may complete automatic approval; request deletion stays on the Requests page. - -- A Collection link card on Movie Details top-aligns its background artwork. Collection Details places `Collection Size` in row one and linked `Genres` in row four of the first detail group. Collection item selection reuses the exact shared season/episode selector appearance; do not create a collection-only selector palette. -- Collection item cards follow the request-summary three-column layout. Their poster and title link to the item; compact availability, release, and linked Genre values occupy the first two groups; and TMDB, Rotten Tomatoes critic, Rotten Tomatoes audience, and IMDb values occupy the third group in that order. Load third-party ratings only as an item approaches the visible scroll region. -- Text links inside detail cards always have a visible underline at rest and a heavier underline on hover. Standard button links keep their button treatment and do not inherit text-link decoration. -- Scrollable card regions use the shared thin scrollbar, transparent track, and stable gutter. Extend the scroll region through its containing card's right padding so the scrollbar meets the inside right edge while equivalent content padding keeps rows clear of the thumb. Header and row grids must reserve the same right-side space so availability columns remain aligned. - -- Issue Details places its summary, affected episodes, description, comments, comment entry, and actions inside one clipped outer artwork-backed card. Every inset region uses the same shared translucency, and no independent artwork fade or mismatched surface opacity is introduced. - -- The global header search is wide enough to display its complete placeholder at ordinary desktop widths. When a user backspaces the query to empty, remove the route query after the debounce and never restore the deleted stale route value into the input. - -## Settings Pages - -- Every application Settings route uses one shared page shell: the visible `Settings` title sits above a fully justified, wrapping row of content-width shared filter-style route buttons. The reserved `Search Settings` control occupies its own row immediately beneath the route buttons and remains presentational until its behavior is explicitly designed. The gap from Search Settings to the main card is 20 pixels, matching the gap from media-page sort controls to their poster results. -- All route content sits inside one shared translucent main card. Each logical group of settings uses one complete darker inset subcard with its own heading, optional description, and fields; do not split a group's heading and fields into independently spaced surfaces. Legacy heading/body pairs must suppress the old 24- and 40-pixel margins and render as one seamless card. General Settings and Playlist Integrations are separate groups. -- Settings subcard headings exactly match Overview card-heading typography: 14-pixel semibold white text with a 20-pixel line height. The main Settings page title remains unchanged. Settings labels, navigation and filter buttons, action-button labels, text fields, and dropdowns use the same 12-pixel standard control text. Settings description and help copy exactly matches Overview body typography: 14-pixel regular-weight text, 20-pixel line height, left alignment, and the shared palette-aware muted detail color. Description and help text span the complete card width beneath the label/control row instead of wrapping inside the narrow label column. Setting names top-align with their adjacent button, badge, selector, text field, or dropdown. Consecutive settings use no more than one body-text row of vertical separation; nested options within one setting use the shared five-pixel gap and never insert blank spacer rows. Every Settings text field, application-styled dropdown trigger, React Select control, Listbox control, attached field action, Search Settings control, and action button uses the shared control height; native select popup rows remain browser-rendered. Multiple setting badges remain together in one non-overlapping row. Boolean fields use the actual shared 16-pixel `SelectionCircle` component instead of native checkboxes or a CSS imitation. Editable Settings routes end with one right-justified action row at the standard five-pixel gap. Cancel is the shared translucent red standard-size action and returns to Discover without saving. `Save` is the shared standard-size green action: it is disabled with the shared dark-green treatment until a field changes, then becomes the normal enabled bright-green action. Route-local duplicate save rows remain hidden behind the shared shell, while their existing form submissions remain the single persistence path. -- Browser Back with unsaved Settings changes opens the shared confirmation modal and keeps the current Settings route in place. Its message states that changes have not been saved; Discard is red and returns to Discover without saving, while Save is green, submits the route's settings forms, and remains on the current Settings page. Refreshing or closing the browser with unsaved changes retains the browser's native leave warning. -- A Settings route starts clean after its data initializes. Only a trusted user edit or a shared custom control's explicit user-change event enables Save; data loading, Formik reinitialization, and programmatic field synchronization must never create a false dirty state. -- Settings tables consume the shared data-table classes for header, row, cell, typography, height, and divider styling. Settings definition lists consume the shared compact list-row classes. Do not reproduce either system with page-local table or list utilities. -- Shared table headings use transparent backgrounds with the standard white heading text and dark-blue divider; a Settings page must not restore a solid header fill. Logs keeps Timestamp, Severity, and Label top-left aligned and uses the shared compact search/filter icon controls. Jobs & Cache uses compact column widths that fit an ordinary desktop card without horizontal scrolling; narrow cards retain the shared scroll fallback. Users reserves adequate width for Requests and untruncated username/email content, then uses compact Role, Joined, and action columns instead of dead space. -- Service instance cards use the standard darker inset surface and five-pixel grid gap. The provider logo sits to the left of the detail-card-size white title; Default, format, 4K, and SSL badges sit together beneath the title. Address and Active Profile use aligned label/value columns with the standard 12-pixel detail-table typography. Edit is the standard-height yellow action, Delete is the standard-height red action, and each Add Server action is the standard-height green action. Override-rule Conditions cards use the same heading, aligned detail columns, inset surface, spacing, and actions without reserving a logo slot. -- Settings warning cards ordinarily use the shared bright-orange warning surface, and their icon and content are vertically centered. The HTTP-login acknowledgement is the explicit yellow exception and uses 14-pixel semibold white text. Adjacent Settings cards retain the standard five-pixel gap; ordinary list rows do not add blank spacer rows or oversized vertical padding. -- About renders Version, Data Directory, and Time Zone as ordinary detail text rather than code-style tags. Shared Settings Cancel navigation targets the real root Discover route; it must never target the nonexistent `/discover` route. -- Plex Libraries is one standard group card. Its library entries use the shared inset-card surface, standard internal padding, five-pixel grid gap, and shared selection circle. Sync Libraries, Select All, and Select None are standard icon actions in one right-justified wrapping row. -- Metadata Provider Status and Metadata Provider Selection are separate standard inset cards inside one Metadata Providers group card. Do not depend on sibling class combinations to synthesize their card boundaries. - -## Pagination Footers - -- Every paginated page uses the shared Issues-style `PaginationFooter`; do not recreate Previous/Next controls inside individual pages or table rows. -- The footer sits five spacing units below the final result region without a separate opaque table-footer surface. `Results Per Page` anchors the left edge, `Page X of Y` remains centered, and compact `Previous` then `Next` buttons share the right edge in that order. -- Previous and Next use the shared small button treatment with four-pixel chevrons. Disabled navigation uses the shared button disabled state. -- Pages may retain their supported page-size choices, but the selector height, segmented label treatment, typography, page-count text, and alignment remain identical everywhere. - -## Users Page - -- The Users page places its controls and user list inside one shared translucent outer card beneath the `User List` page title. Its `Filters` section contains Keyword Search, Type, and Role; its `Sort By` section contains Joined, User Name, Requests, Type, and Role using the shared compact filter controls. -- The user list is the first inset subcard and uses the shared data-table typography and two-pixel dark-blue heading divider. It displays ten 48-pixel user rows within a 512-pixel-high scroll region, keeps the heading row visible, and uses the shared selection circles instead of native checkboxes. Its content-based column widths fit an ordinary single-window card without horizontal scrolling; the table retains a smaller page-specific minimum width so horizontal scrolling appears only when the window becomes genuinely narrow. -- The standard-size Create Local User, Import Media Server Users, and Bulk Edit actions form one right-justified wrapping row immediately beneath the table. The Users page is intentionally not paginated: do not render a page-size selector, page count, Previous button, Next button, or a page footer. - -## Metadata Source Attribution - -- Movie and series detail pages show a compact `Metadata sources` row beneath the overview. Each source name links directly to the source record; TVmaze is marked `adapted` and includes its `CC BY-SA 4.0` license link, while Wikidata includes its `CC0` label. Keep this row readable at narrow widths and visible without opening a disclosure. -- The About page shows the approved TMDB logo beside the required non-endorsement notice: `This product uses TMDB and the TMDB APIs but is not endorsed, certified, or otherwise approved by TMDB.` Keep the logo small and secondary to SeerrNG branding. -- TheTVDB receives a direct source link. Do not use TVDB or TVmaze artwork in attribution or metadata fallback unless their image-use terms are separately satisfied. -- Source links and supplemental metadata use the shared `.media-metadata-attribution`, `.media-metadata-supplemental`, and `.media-tmdb-attribution` classes in `src/styles/globals.css`; do not recreate their appearance with local utility or inline styles. - -## Accessibility and Behavior - -- Interactive controls retain a visible keyboard focus ring. -- Profile pictures follow account ownership. Plex, Jellyfin, and Emby accounts use the picture supplied by that provider and do not expose a competing Seerr upload control. A signed-in local user sees a compact `Edit` control on their profile picture and may upload a JPEG, PNG, or WebP image up to 5 MB. Uploaded pictures are normalized to a square asset, stored in the persistent application data area, and versioned so replacements refresh immediately. -- Toggle and reset buttons expose their selected state through `aria-pressed` where applicable. -- Icon-only buttons require an accessible label and a descriptive tooltip. -- Permission-controlled actions are not rendered for users who cannot perform them. -- A provider failure must display the provider-specific recovery message. Cached results may remain visible, but a generic success or zero-results state must not hide the outage. -- Short empty-state and status labels do not end in sentence punctuation (for example, `No Comments`, `No Results`, and `No Tracks Available`). Full explanatory or error sentences retain normal punctuation. - -## Review Checklist - -When changing a shared control or badge, verify every in-scope page that uses the same element. Confirm default, active, hover, focus, disabled, open, narrow-window, and error states before accepting the visual change. Run `pnpm ui-style:check`, `pnpm current-batch:check`, and `node --test bin/check-current-batch-contract-lib.test.mjs`. A successful typecheck or build does not replace visual inspection. +# UI Style Standard + +This document defines the accepted, reusable visual and interaction standards. [UI Fix-It](ui-fix-it.md) defines how to detect and repair drift. Keep these two directions separate: this file describes the target; the repair file describes the audit. Neither replaces the project's recovery journal, release policy, security policy, or human visual acceptance. + +The rules describe asset roles, not particular pages, title strings, example media, or source-code snippets. Apply them to every approved consumer of a role. An implementation still containing legacy styling is drift to classify, not permission to create a new exception. Unreviewed designs remain provisional until explicitly accepted. + +## 1. Shared ownership and scope + +1. Use global, reusable, descriptive CSS families. Do not create a class per page, duplicate a utility under a new class name, or retain competing local declarations merely because they look correct today. +2. Give each property one effective owner. Typography, geometry, palette, layout, surfaces, effects, icons, responsive behavior, and scrolling belong to their appropriate shared roles. Components select a role or a supported semantic variant; they do not reproduce its values. +3. Reuse existing variables before adding one. A new variable must represent a genuine independently adjustable property. A new family must represent a reusable asset role with no suitable existing owner, and requires agreement before adoption. +4. Keep appearance separate from arrangement. All buttons use shared button styling, but their order, grouping and justification depend on the surrounding layout. There is no universal rule placing playback, ratings, quality, or other actions into a particular row. +5. Use data attributes or supported component options to select semantic variants. Runtime CSS values are allowed for genuine data geometry, such as a progress fraction, measured bounds, or pointer coordinates; not for local font, padding, color, or button-size overrides. +6. Trace the complete spacing boundary. Parent gap, child margin, container padding and fixed-header offsets must not accidentally add together. Do not use spacer nodes, negative offsets, or per-control optical adjustments to conceal duplicate owners. +7. Preserve behavior, permissions, provider branding, asset aspect ratios and meaningful media-type differences during presentation changes. A layout refactor does not authorize an API, database, or permission change. +8. Scope statements precisely. A page audit includes its rendered dependencies and active conditional branches, but does not establish that unaudited pages or all global CSS are clean. Deferred roles must remain explicit. +9. Do not introduce Tailwind into page assets, including structural layout, responsive classes, generated strings, transition props or `@apply` at migrated owners. Existing legacy use is reported and repaired within the approved scope, not treated as proof that new utility styling is acceptable. + +## 2. Role map + +1. Page identity: `page-title`; section and shelf identity: `page-heading`. +2. Page container and heading boundaries: `page-layout`; title/status row: `page-title-row`; aggregate activity: `page-status`. +3. Main, nested and inset card arrangement: `card-layout` and the shared card families. Content typography: `card-title`, `card-subheading`, `card-body-text`; compact sizing: the existing compact card variant. +4. Tables: `card-table`, `card-table-heading`, `card-table-value`, with descriptive layout variables. Lists and bounded scrolling: `card-list`, `scrollable-card`, and the appropriate shared card body/frame roles. +5. Browsing and shelf posters: `poster-layout`, `data-media-type`, and `data-poster-region`; presentation: the existing poster frame, `poster-control`, and content typography owners. +6. Ordinary actions: the shared Button component and `app-button` plus its standard size and semantic palette. Poster actions: `poster-control`. Filter/disclosure controls: their shared segmented/filter families, not copied ordinary-button geometry. +7. Dropdowns: the existing global dropdown, compact select, provider container and provider icon roles. Overlapping screens: `page-overlay` and `page-overlay-card`. +8. Messages and recovery: `page-error-message` and the shared Retry action. Selection: `selection-circle`; hierarchical selection: `selection-tree` together with table/card roles. + +This map identifies owners, not a whitelist of every valid class. Follow the existing family for the asset and verify its cascade before proposing another family. + +## 3. Typography and copy + +1. Every page has a permanent visible page title in populated, loading, empty and failed states. A browser-tab title is not a substitute. +2. Page titles and section headings share the established indigo-to-purple text treatment and bold weight. Obsolete brand-named or ambiguous alternate title classes must not compete with these roles. +3. Current typography values are: + + - Page title: 24px text / 28px line below the 640px breakpoint; 36px / 40px at and above it. + - Page heading: 24px / 28px. + - Card title: 18px / 20px. Accepted regular content-title treatment selects weight 400 through the shared title variant; do not change table headings to accomplish this. + - Card subheading: 14px / 20px, regular italic; 4px before it. + - Card body: 14px / 20px, regular; 16px before it. Fully justify paragraph text; the final line follows the reading direction without stretching. + - Table heading: 12px / 16px, weight 600. Table value: 12px / 16px, weight 400. + - Compact card title: 14px; subheading: 12px; both use 16px line height. + +4. Define title and heading line height in the typography owner as text size plus 4px. Recompute it with the responsive font-size variable in the consuming role; do not inherit a precomputed line height from an earlier breakpoint. +5. Use Title Case for UI titles, headings, control labels and heading-role message text, including active English translations and generated labels. Audit by role, not by an inventory of example strings. Preserve proper names, provider branding, user-entered names, identifiers and sentence-case prose/values. Do not blindly transform arbitrary localized text at runtime. +6. Short card/message titles have no trailing period. Full sentences retain normal punctuation. Use truthful descriptions and action-oriented tooltips; do not describe a simulation as a real saved state. +7. Links represent actual valid destinations. Distinguish linked names from descriptive facts; a role/character/subtitle with no destination remains plain text. Retain the established underline and hover/focus treatment rather than inventing clickability. +8. Typography variants change the necessary variable in the existing family, not an entire duplicate class. The compact portrait/credit card is fixed at 70px high with 1px top and bottom padding, the shared 4px title/subheading gap and 8px horizontal inset. Do not calculate a different height per item, add clipping exceptions, or shrink individual names to fit. +9. Accepted request/overlap title roles, sign-in identity titles and linked profile identity names also consume `page-title`, preserving their layout and links. This does not recategorize table headings or card headings as page titles. Future size variations require agreement. + +## 4. Page, card and shelf layout + +1. `page-layout` owns page margins and heading-container padding. Current top margin is 0; inline gutters are 16px. The page-title boundary has 0 before and 14px after; a section-heading boundary has 20px before and 14px after. +2. Preserve the main search/header container's own balanced padding. Apply its fixed-header offset and safe-area allowance exactly once. Page line height controls text rhythm, not the external distance from the search bar or following content. +3. Shared card spacing is 8px. Main and inset padding are 8px. Title-to-detail-table spacing consumes that existing card-spacing owner, rather than an independent 14px value. Independent nested-card/background settings use the same family with supported variables. Framed cards consume the shared frame's border and corner geometry; a surface role does not duplicate that ownership. An existing standalone consumer may retain its established geometry through an explicitly scoped, low-specificity fallback. +4. A card's layout selects its own column/row arrangement without re-owning typography or button appearance. Use the shared action-row justification variable for alignment differences; retain wrapping and the standard card gap. +5. Shelf headings, adjacent view controls and previous/next actions are real children of one row. Navigation sits at the far right on the same baseline, so its lower boundary matches the heading's content boundary. Do not position it as an independent absolute row or add vertical corrections. +6. Poster width is fixed at 169.2 CSS pixels; the frame aspect ratio is 2:3, producing 253.8 CSS pixels high. Window resizing changes the number of columns, not poster size. Preserve the accepted 1.05 hover enlargement. +7. `poster-layout` owns browsing/shelf geometry across media types. Media type selects content and semantic region slots. Frame roles own ratio; content outside the image frame must not be clipped by applying that ratio to an entire article. Portrait, mosaic and provider-logo arrangement differences remain meaningful; compact credit cards and detail-card posters are distinct roles. +8. Standard poster titles use the 18px card-title owner with the accepted regular weight. Do not reintroduce local typography or responsive shelf enlargement that competes with the fixed poster owner. +9. Background artwork uses the shared card-layout position variable, currently top-aligned. Small poster artwork retains its independent centered treatment. Shared background, transparency and readability effects belong to card families; future effects are not approved merely because the architecture supports variables. +10. Keep scrolling inside an overflow child of a stationary card frame. Preserve thin scrollbars, transparent tracks and stable gutter alignment with the card's inset. Header rows stay outside the rows-only scroller; do not use an overlapping sticky header to hide alignment problems. +11. Loading placeholders consume the geometry owner of the card family they replace, including responsive width and baseline height. Keep loading paint and animation separate from layout ownership; do not copy dimensions into a skeleton or reuse a different card family's aspect ratio. Preserve established content growth, recovery actions and reduced-motion behavior rather than forcing every state into a clipped fixed-height box. + +## 5. Theme, surfaces and branding + +1. Preserve the established named palette anchors and semantic treatments. Page canvas, gradients, spotlight/diagonal effects and card readability treatments have shared theme owners; do not replace them with page-local colors. +2. Preserve accepted alternative themes and the opt-in Visual Lab palette reference pages. Provisional color/tree experiments are not a new site default. A theme-specific setting does not authorize rewriting other themes. +3. Keep the shared 46% primary-blue artwork readability wash and horizontal 18/32/58% light-to-deep-blue gradient, including the existing browser fallback. Dark artwork overlays remain readable independently of light/dark UI mode. The fallback uses the same cached artwork URL and crop owner; do not add a second fetch, scrim, or page-bottom fade to compensate. Artwork covers and clips to its owning card rather than leaking across the surrounding overlay; a main artwork card can contain direct content/inset cards without a redundant second artwork shell. +4. Surface opacity is family-specific, not one universal number. Existing standard action palette families use their established resting/hover/active values (55/70/85% where defined); media-type action treatments and poster surfaces have independent established owners (including the 70% poster treatment). Preserve the relevant family instead of applying its values to unrelated controls or duplicating them into callers. +5. Semantic action colors retain their roles: red destructive/failure/cancel; green approval/success and previous/next; yellow warnings/issues; orange trailer/random-refresh; purple management; cyan associations; blue neutral tree/search actions. Native playback and saved actions retain their established black playback treatment with provider logos. Preserve existing named family variants and meaningful media-type palette differences. +6. Keep text and icon readability effects in shared owners. Filters/sort controls have their explicit shadow-free treatment. Preserve native focus, hover, active and disabled effects; do not make a disabled control look enabled or remove its reason tooltip. +7. Preserve native provider logos and their meaningful aspect ratios. Shared button height does not require stretching a wordmark into a square. Product names and branding must remain accurate; do not leave obsolete branding as a general style-class name. +8. Keep decorative card framing separate from body paint. Preserve the shared masked frame treatment rather than filling the entire body as a blue border box. Focus/selected feedback must not thicken inward and change actual control geometry. +9. Shared palette recipes and their deliberately spelled `metalic-*` and `gradiant-*` variants belong to the stylesheet, with complete explicit role-class strings selected by components. Do not silently rename these existing families or construct partial palette class names dynamically. Visual Lab consumes production owners or an explicitly opt-in experimental family; generated references remain traceable to their source and must not become a second competing implementation. + +## 6. Buttons, filters and dropdowns + +1. Use the existing shared Button or established role component. Raw HTML/headless controls must attach both appearance and their correct size owner; `app-button` alone is not proof of standard geometry. General action palettes use their semantic color variant rather than borrowing an unrelated provider role. Action icons follow the action currently offered, remain decorative beside an accessible label, and consume the shared SVG geometry rather than local dimensions. +2. Current ordinary action height is 16px and content/icon height is 14px, with 12px text, 5px horizontal padding, a 4px icon/text gap, 1px border and 6px corner radius. The shared standard/md/sm aliases consume the same action-size owner. An icon-only width is derived from content height, padding and borders, not a new 24px rule. +3. Poster, segmented filters and button-like dropdown triggers consume their shared 16px action-size owners. Poster internal icon/content spacing may use its established independent gap. Editable text inputs retain their distinct 20px compact input role; dropdown buttons do not inherit that editable-input height. Rich selectable cards and content-sized menu options remain their own roles, not compact triggers. +4. Delete local SVG dimensions, margins or flex sizing when the shared control role already owns them. Check every conditional branch, including single-option controls: attach a missing branch to that existing owner before deleting its local size. Preserve meaningful provider-logo geometry. Do not copy a class's numeric values into another class just to remove Tailwind from JSX. +5. Dropdown opening/closing effects belong to shared dropdown/button roles and respect reduced motion. Popup positioning, split-container layout and menu surfaces each have one owner. Do not assume deleting transition props removes their behavior unless native CSS replaces it. +6. Compact selects fit their labels/values; the value remains before and separate from the chevron. Menus may have independent width and a shared bounded viewport. Preserve country-flag external classes and provider logo semantics, not local utility-based geometry. +7. Filter section controls sit above the section they control, with shared spacing and wrapping. Keep media/context/state filters distinct. An active filter highlights the intended label segment, not unrelated controls or the whole row. +8. Keyword input text stays readable; its input width uses the shared owner and can shrink within available space. Matching follows meaningful visible metadata, documented term/phrase behavior and the actual filter state. Clearing must clear both pending/debounced and active state; hidden-only metadata must not silently expand matches. +9. Pin and disclosure segments have symmetric shared padding. Rotated artwork must not introduce extra left padding. Pin state is distinct from disclosure state and persists through the existing per-user/media-category contract. A pin transition expands its section; an unpin transition collapses it. Manual disclosure toggles remain independent. CSS owns appearance, while the shared component and saved-state contract own interaction; saved-state recovery must reconcile the matching section without disturbing others. +10. Icon-only controls have an accessible name and descriptive tooltip. Disabled tooltips remain available without activating the control or parent poster; click and keyboard propagation must respect the actual interactive slot. +11. Pagination uses the shared component: results selector, table-sized page counter and green navigation. Numeric value width is content-driven with a separate chevron; no overlap or hardcoded width per digit count. +12. Status badges are classified by role and interaction: noninteractive status, poster overlay, actionable control, selection indicator or availability. They must not become copied button families for each status string. Automatic queued/actionable status uses the existing standard green control treatment where approved. +13. Native select popups may be operating-system controlled. Do not claim pixel-identical popup theming through ordinary CSS where the platform does not support it. Use the established shared custom select where its role requires consistent menu surfaces, checkmarks and effects; do not silently change behavior to escape a styling limitation. +14. A shared select/listbox family owns the value's overflow behavior and its option selection emphasis, not utility classes attached by callers. State attributes carry selection/interaction meaning; authored CSS supplies the presentation. Preserve shared trigger sizing, palette feedback, disabled behavior, content-fit menu options, portal geometry and reduced-motion transitions when migrating its internals. +14. Poster watchlist controls retain their accepted icon-only outline/solid star, pressed state, yellow treatment and white hover. Associations and blocklist actions retain meaningful tooltips and their permission/confirmation behavior. Do not alter blocked-item visibility, current quality/status placement, or mutation eligibility as an unannounced style fix. +15. Custom action menus use the established opaque black shared menu surface. An open menu must escape its card without clipping or having an ancestor's decorative frame paint through it. Preserve closed-card framing and keyboard/focus behavior; trace ancestor stacking contexts rather than relying on a menu's numeric z-index alone. Do not copy the shared surface into a page-local override. + +## 7. Tables and hierarchical selection + +1. Table typography and layout live in `card-table`. Semantic heading/value roles remain in that family. Descriptive table-layout variables select columns and placement; detail, history and selection layouts must not overwrite one another's configuration. +2. Main summary and expanded detail groups may use distinct supported layout configurations without creating page-specific typography. Wrapped/two-line values are explicit table variants, not inline overrides. +3. Column dividers belong to the owning column, not separate divider tracks that consume content width. Preserve the shared 2px divider treatment and responsive transition between stacked and side-by-side groups. Headers and rows share tracks, padding and scrollbar geometry. +4. Selection and availability are different semantics but share visible circle geometry. The selection glyph uses the shared 16px box and the availability SVG circle bounds (24-unit view box, radius 9, stroke 1.5), not a full-box CSS border. Preserve selected, partial, disabled and focus states. +5. A selection tree uses shared card/table roles, a stationary toolbar/header and one bounded content scroller. The current compact preset shows six content rows in a 186px frame; episode text is 12px / 14px with shared derived vertical padding. A different tree preset requires agreement, not a copied class. +6. Disclosure expands/collapses and selection selects; neither implicitly performs the other. Tree groups start collapsed. Expansion changes must not silently change selection or lose existing selections. +7. Build one canonical ordered selection from source-backed item identity. Eligibility follows the action: playback selects available library items; requests select missing, unrequested items. Keep availability facts independent from selection eligibility and keep unavailable rows visible. Selection totals must not silently omit unavailable source items from their denominator or include out-of-scope items. Reuse existing action/payload logic when changing presentation; adapt row identity at the boundary rather than rewriting backend behavior. Incomplete metadata must not silently discard selections or enable partial-data submission. +8. A selectable episode has one accessible selection target covering its indicator, number and title, with date/availability/watched facts outside that target. Reuse the table/subgrid tracks; never nest buttons or trigger selection twice through bubbling. +9. Watched state is source truth: check for watched, short dash for unwatched, question mark for unknown. Missing dates are truthful placeholders. Preserve calendar/date semantics, readable abbreviated weekday and descriptive full-date help; do not fabricate metadata or infer watched state from availability. +10. Inline tree guides/highlights belong to the shared selection family; they must not add opaque per-row cards that conceal artwork. Keep provisional theme variants opt-in. Display counts use the shared formatter and color/state owner, not independent local typography. +11. The media-server workspace combines the remaining-width table area and content-sized action area using shared layout gaps. Preserve the accepted arrangement while refactoring; button placement is not a universal standard. Hidden panels leave layout and hit testing but remain stably mounted when required to preserve selection/state. + +## 8. Page activity, messages and recovery + +1. Show one aggregate status at the far right of the permanent page-title row. Initial loading, searching, panel fetches, refresh and actual mutations contribute to that page status; nested panels do not add a second spinner. +2. `page-status` uses 18px orange text and a blue 1em spinner with the accepted thicker stroke. Labels are Title Case; searching has its documented precedence. Idle status leaves no placeholder or blank row. Contributors clear their activity on completion and unmount. +3. Message severity describes purpose: red for failed fetches/operations, failures and destructive/error messages; orange for caution, non-destructive warnings or missing/empty information; yellow for non-default runtime/context information. All message text is white. Preserve comments explaining these semantic choices beside the shared variables. +4. Message cards fit their content and wrap within the viewport. In content-sized action areas, feedback must not determine intrinsic column width or collapse neighboring content; it flows below the related control within the layout's existing bounds. They use standard card padding/gap. The title uses card-title typography and Title Case without a final period; descriptive sentences retain ordinary punctuation. Retry appears below the message, not beside it in a desktop-only row. +5. A failed refresh preserves the last successful content. Successful emptiness is not automatically an error: distinguish restrictive filters, unfiltered emptiness, unpublished metadata and fetch failure. A successfully loaded empty episode list need not invent an error. +6. Panel messages appear below the panel/table heading and before its content. They do not recolor the rest of the panel, table, icons or existing content. Missing informational metadata and source failure must not be conflated. +7. Retry belongs to the failing source and has a real safe recovery callback plus descriptive tooltip. Use the shared Retry label/icon and busy animation, with reduced-motion support and duplicate-call protection. Do not wire every Retry to page reload or an automatic provider mutation. +8. Overlapping screens use `page-overlay` for the established black 80% backdrop, viewport positioning, scrolling/alignment and 300ms opacity effect; `page-overlay-card` owns the foreground opacity/scale effect. Preserve reduced motion and retire the competing backdrop owner only after all consumers migrate. + +## 9. Ratings and truthful data + +1. Video ratings keep six persistent slots: Rotten Tomatoes critics and audience, IMDb, Metacritic, Trakt and TMDB. Shared `VideoRatings`/`CollectionRatings` owns their layout and presentation in detail and hover contexts. +2. Unknown, unavailable or still-missing scores display exactly `--`; retain the logo and reserved value geometry. Preserve valid zero, reject nonfinite/out-of-range values, and retain known cached scores during revalidation. Never invent review ratings or translate a source failure into zero. +3. Only a known score with a verified safe destination is a link. Use media-correct provider URLs. Music/book ratings and aggregate collection scores preserve their own truthful source semantics; the video six-slot rule is not copied into them. +4. UI actions and count/status displays must reflect real eligibility and permissions. Preserve meaningful zero IDs, source identities, group membership and selected-quality context; do not guess data to satisfy a layout. +5. Timeline/request progress uses the shared green presentation with a visible checkmark; overflow controls appear only for actual measured clipping. Preserve status meaning and recovery/action permissions rather than treating progress as a decorative bar. +6. Profile images follow account ownership. Provider-linked accounts retain their provider image rather than a competing local upload action. The local account image role accepts JPEG, PNG or WebP up to 5 MB, normalizes the image to a square, and uses the established persistent, versioned upload contract; a style audit must not change that security/storage behavior. +7. Detail pages retain a compact metadata-source attribution row, readable at narrow widths. Movie attribution stays beneath the overview, visible without opening a disclosure. Series attribution and production credits sit at the bottom of the expandable Details card; the Overview disclosure contains tagline and synopsis. Overview defaults to the left of the shared reorderable row until explicitly repositioned. Series disclosure controls distribute across their full row with shared minimum spacing and wrapping; distribution never changes control geometry. Link each source name directly to its source record. Identify adapted TVmaze data and retain its `CC BY-SA 4.0` license link; identify Wikidata's `CC0` license. +8. About retains the approved TMDB logo as secondary branding beside the required notice: `This product uses TMDB and the TMDB APIs but is not endorsed, certified, or otherwise approved by TMDB.` +9. TheTVDB attribution links directly to its source record. TVDB and TVmaze artwork must not enter attribution or metadata fallback unless their image-use terms are separately satisfied. +10. Metadata-source links, supplemental facts and TMDB attribution consume the shared `media-metadata-attribution`, `media-metadata-supplemental` and `media-tmdb-attribution` families in the authoritative stylesheet. Preserve their source/credit semantics without local utility or inline presentation copies. Newly integrated attribution presentation still requires the applicable desktop/narrow visual review; preserving its implementation does not establish human acceptance. + +## 10. Native saved actions and identity safety + +These are implementation contracts, not a statement that a staged control has been published or verified against a real provider account. + +1. Keep local application watchlist, native Plex Watchlist, Jellyfin/Emby Favorites and native Collections distinct and accurately named. Plex Watchlist uses the current linked user's account and trusted cloud catalog identity; native item IDs, playlists, playback queues and personal Lists are not substitutes. +2. Whole-series Favorites and Collections use the trusted selected-quality series root. Never silently use an HD root while 4K is selected. Plex Watchlist membership is format-independent. Do not imply selected episodes are saved when only the whole series is supported. +3. Existing Collections are library/server collections, not guaranteed private personal lists. Permit only verified native collection-management authority; Plex collection mutation requires verified server-owner authority. Do not silently borrow an administrator token, or create, rename or delete collections through a choose-existing action. +4. Native saved memberships are independent. Do not automatically transfer, remove, synchronize, request, download or consume queue items. Existing Plex Watchlist watcher settings/permissions remain authoritative and may observe a new item naturally; the action must not enable them or directly create a request. +5. Server endpoints use authenticated own-user context, strict validated IDs/query/body, trusted native item mapping, existing CSRF/authentication/locks, bounded work and sanitized errors. Tokens and native account authority do not come from the client or appear in errors/logs. Verify identity and state before and after a desired-state write. +6. Jellyfin verifies current account identity through native supported identity APIs. Emby's stored user/token pair must come from the same successful native login/linking provenance and pass a fresh own-profile/access check; do not claim independent token-owner introspection or infer it from another user's session. +7. Clients key cached state by user, linked identity, provider, series and quality using the actual fetcher contract. Do not send unsupported identity query parameters to a strict route. Revalidate current context before writing, prevent duplicate mutations, accept only confirmed provider state, discard late responses and reset failures when context changes. +8. Explicitly inactive integration wrappers and embedded prototypes mount no native fetch/mutation hooks. Busy/unavailable buttons cannot mutate; authorized state reads may continue while the button is disabled for loading. Busy fetch/mutation contributes to the page's single status. Recovery refreshes identity/state, not an unconfirmed mutation. Context loss, unauthorized capabilities and source errors fail closed with truthful help. +9. Functional buttons use the existing playback style, shared size and the active provider logo, with accurate Add/Remove Watchlist, Favorites or Collection copy and descriptive tooltips. Static review controls have no mutation handler; visually enabled appearance does not constitute functional acceptance. +10. Mutable saved membership is current provider state, not cacheable catalog metadata. Initial and pre-write reads must be fresh across client, API-response and service-worker layers. A desired-state action may skip a write only after fresh, identity-verified state confirms that exact desired state; neither a stale result nor an optimistic label proves success. + +## 11. Reordering disclosures + +1. A quick click retains normal toggle behavior; the independent pin segment remains independent. A one-second primary-pointer hold enables reorder readiness and its shared visual feedback. +2. Once dragging, an inert, noninteractive copy of the actual control follows the cursor/grab offset. The original represents the draft gap and siblings show draft order. Draft hover does not save preferences or move live panels. +3. Commit control and panel order together only on a valid release. Outside release, Escape, blur, matching pointer cancellation or lost capture restores the original order. Freeze anchor bounds as appropriate and validate release position to avoid oscillation or stale hover commits. +4. Ghosts copy no identifiers, event handlers, focus stops or control references. Shared CSS owns effects; only measured geometry/pointer values are runtime variables. Reduced-motion users receive nonanimated readiness feedback. +5. Keep stable keyed panels mounted so reorder does not reset selections, open state or source data. Persist a normalized per-user/media-category role order through self-only authorization. Missing roles append predictably; pin and selection state are not part of the order payload. +6. Provide accessible keyboard reorder instructions and status. Failed saves roll back, and identity changes/late responses cannot overwrite another user's order. Do not claim touch or native round-trip behavior solely from mocked pointer tests. + +## 12. Verification, acceptance and records + +1. Use affected focused checks during approved page/asset iterations; label their previews as iterations, not completed review candidates. Run the complete `pnpm validate:development` gate on the exact final candidate, before commit/PR, and on the integrated tree after conflicts. Inspect discovery with `pnpm validate:development --plan`. Public build/development entry points and the local commit hook remain fully gated. `pnpm build` includes validation already; do not duplicate it immediately beforehand. Internal compile/server aliases and bypassable local hooks are not finalization evidence. Existing CI workflows remain unchanged. +2. Separate source ownership checks, compiled/effective CSS checks, component behavior tests, type/lint results, HTTP health, human visual acceptance and real provider round trips. None substitutes for another. State the audited dependency scope and exclusions explicitly. +3. Generic checks target roles/owners and relationships, not exact example headings, media names, routes or message text. Parse dynamic class composition, rendered branches and transitions; verify effective native CSS rather than stale Tailwind token strings or naive first-brace extraction. +4. A failed ownership/security gate blocks completion. Unknown semantics, conflicting standards or a new reusable family require discussion. Known unrelated baseline failures require an explicit scoped record, not deletion or weakening of the assertion. +5. Record every change in the project recovery journal in the same work unit: affected asset and owner, files, old/new values when known, authoritative source identity, target and recovery archive, validation results, implementation status and accepted/pending visual status. Keep historical decisions and rollback artifacts; standards and checkpoints do not replace the ledger. +6. Portable standards omit project-specific preview addresses, archive IDs and review-page narratives. Keep those facts in the journal or task records. Lists of issues needing human review are numbered so decisions can be referenced unambiguously. +7. `pnpm test` runs the discovered test partitions, not static/style/lint/type/governance checks; `pnpm test:ci` is Vitest-only. Discovery excludes browser/Cypress suites, which require separate applicable review. Retain selected-file inventory and actual execution logs/counts/skips separately. Platform-excluded tooling is unrun, not passing; use Linux for complete POSIX tooling parity. +8. Small audit batches do not erase earlier acceptance. Preserve Request-page changes and shared typography, aggregate loading/status, button/poster geometry and layout owners across the contribution. An unrelated backend failure is a reported blocker, not authority for a sweeping repair. Required failures are never waived into a passing result. diff --git a/package.json b/package.json index e3751cec6..4102ca5b2 100644 --- a/package.json +++ b/package.json @@ -7,11 +7,17 @@ "preinstall": "npx only-allow pnpm", "postinstall": "next telemetry disable", "dev": "nodemon -e ts,json,yml --watch server --watch seerr-api.yml --exec 'ts-node -r tsconfig-paths/register --files --project server/tsconfig.json server/index.ts'", + "dev:server": "nodemon -e ts,json,yml --watch server --watch seerr-api.yml --exec 'ts-node -r tsconfig-paths/register --files --project server/tsconfig.json server/index.ts'", + "dev:checked": "pnpm dev", "build:server": "tsc --project server/tsconfig.json && copyfiles -u 2 server/templates/**/*.{html,pug} dist/templates && copyfiles -u 2 \"server/i18n/locale/*.json\" dist/i18n && tsc-alias -p server/tsconfig.json && node scripts/replace-server-import-aliases.mjs dist", + "build:server:compile": "tsc --project server/tsconfig.json && copyfiles -u 2 server/templates/**/*.{html,pug} dist/templates && copyfiles -u 2 \"server/i18n/locale/*.json\" dist/i18n && tsc-alias -p server/tsconfig.json && node scripts/replace-server-import-aliases.mjs dist", "build:next": "next build --webpack", + "build:next:compile": "next build --webpack", "prebuild": "pnpm i18n:check && pnpm current-batch:check", "build": "pnpm build:all", + "build:compile": "run-p build:next:compile build:server:compile", "build:all": "run-p build:next build:server", + "validate:development": "node bin/run-local-validation.mjs", "bundle:check": "node scripts/check-bundle-size.mjs", "bundle:baseline": "node scripts/check-bundle-size.mjs --write-baseline", "lint": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\" --cache", @@ -32,8 +38,8 @@ "start": "NODE_ENV=production node dist/index.js", "i18n:extract": "ts-node --project server/tsconfig.json server/i18n/extractMessages.ts", "i18n:check": "node bin/check-i18n.js", - "current-batch:check": "node bin/check-current-batch-contract.js && node bin/check-refreshed-ui-style.js", - "ui-style:check": "node bin/check-refreshed-ui-style.js", + "current-batch:check": "node bin/check-current-batch-contract.js && pnpm ui-style:check", + "ui-style:check": "node bin/check-refreshed-ui-style.js && node --test src/styles/buttonGeometry.test.mjs", "migration:generate": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:generate -d server/datasource.ts", "migration:create": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:create -d server/datasource.ts", "migration:run": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:run -d server/datasource.ts", diff --git a/public/sw.js b/public/sw.js index dd8672061..b0f5daf57 100644 --- a/public/sw.js +++ b/public/sw.js @@ -76,6 +76,17 @@ const getRuntimeCacheType = (request) => { return undefined; } + if ( + /^\/api\/v1\/tv\/\d+\/media-server-(?:collections|saved-item)(?:\/|$)/.test( + url.pathname + ) + ) { + // Membership and saved state can change directly in the media server and + // are re-read before every write. Never let a runtime entry become their + // authority. + return undefined; + } + if (CACHEABLE_API_PATHS.some((pattern) => pattern.test(url.pathname))) { return 'user-data'; } diff --git a/release-notes/2026-10-02-filter-titles-and-watchlist-toggle.md b/release-notes/2026-10-02-filter-titles-and-watchlist-toggle.md new file mode 100644 index 000000000..0fe485f9e --- /dev/null +++ b/release-notes/2026-10-02-filter-titles-and-watchlist-toggle.md @@ -0,0 +1,9 @@ +--- +category: fixed +audience: users +area: interface +action: none +breaking: false +--- + +Browse filters, titles and loading states retain shared styling. Request pins expand and collapse correctly. Series browsing gains a yellow watchlist visibility action. Series requests reuse the season/episode tree with existing submission rules and a compact Episode Queue beside it. Missing metadata blocks submission until recovered. Streaming-service choices retain accessible native controls. diff --git a/release-notes/2026-10-02-interface-verification-guidance.md b/release-notes/2026-10-02-interface-verification-guidance.md new file mode 100644 index 000000000..22c35b80d --- /dev/null +++ b/release-notes/2026-10-02-interface-verification-guidance.md @@ -0,0 +1,9 @@ +--- +category: changed +audience: operators +area: development +action: none +breaking: false +--- + +Interface guidance separates focused checks from cumulative review, preserves Requests and shared visual roles alongside Series changes, and supplies an integration checklist. Unrelated backend failures require scope review. Before expensive cumulative runs, contributors verify complete repository fixtures and native tooling; an app snapshot alone is insufficient. diff --git a/release-notes/2026-10-02-native-saved-state-fixes.md b/release-notes/2026-10-02-native-saved-state-fixes.md new file mode 100644 index 000000000..5d57f2687 --- /dev/null +++ b/release-notes/2026-10-02-native-saved-state-fixes.md @@ -0,0 +1,9 @@ +--- +category: fixed +audience: users, operators +area: media-server +action: none +breaking: false +--- + +Plex Series Watchlist and Collection actions now refresh membership correctly. Compatible Collections are recognized, and removal affects only the selected item's membership. Playback menus use the shared solid-black surface and open above their card frame without clipping. diff --git a/release-notes/2026-10-02-request-icon-owner.md b/release-notes/2026-10-02-request-icon-owner.md new file mode 100644 index 000000000..e21ce5a20 --- /dev/null +++ b/release-notes/2026-10-02-request-icon-owner.md @@ -0,0 +1,9 @@ +--- +category: fixed +audience: users +area: interface +action: none +breaking: false +--- + +Single-option Request controls now use the same shared icon sizing as multi-option Quality/Request controls, instead of a competing local utility. diff --git a/release-notes/2026-10-02-request-native-controls.md b/release-notes/2026-10-02-request-native-controls.md new file mode 100644 index 000000000..a0e70b7b3 --- /dev/null +++ b/release-notes/2026-10-02-request-native-controls.md @@ -0,0 +1,8 @@ +--- +category: fixed +audience: users +area: requests +action: none +breaking: false +--- +Requests dropdowns, pagination, filter disclosures, card surfaces and tooltips use shared native styling, preserving geometry, palette variants and keyboard focus while respecting reduced motion. Software request cards reuse shared padding and card-spacing rules. diff --git a/release-notes/2026-10-02-request-slider-loading-size.md b/release-notes/2026-10-02-request-slider-loading-size.md new file mode 100644 index 000000000..583367d38 --- /dev/null +++ b/release-notes/2026-10-02-request-slider-loading-size.md @@ -0,0 +1,9 @@ +--- +category: fixed +audience: users +area: requests +action: none +breaking: false +--- + +Recent Requests loading placeholders now match the compact cards at narrow and wide screen sizes, keeping the slider consistent as items load. diff --git a/release-notes/2026-10-02-series-media-server-controls.md b/release-notes/2026-10-02-series-media-server-controls.md new file mode 100644 index 000000000..d1fdbd9d1 --- /dev/null +++ b/release-notes/2026-10-02-series-media-server-controls.md @@ -0,0 +1,9 @@ +--- +category: added +audience: users, operators +area: media-server +action: none +breaking: false +--- + +Series details now offer an expandable season/episode selection tree and per-user disclosure ordering. Linked users can manage the Series in Plex Watchlist, Jellyfin/Emby Favorites, and existing authorized server Collections without creating Collections or changing download requests. diff --git a/release-notes/2026-10-02-shared-development-validation.md b/release-notes/2026-10-02-shared-development-validation.md new file mode 100644 index 000000000..691fcea62 --- /dev/null +++ b/release-notes/2026-10-02-shared-development-validation.md @@ -0,0 +1,9 @@ +--- +category: changed +audience: operators +area: development +action: none +breaking: false +--- + +Development, public builds and the local commit hook now share a validation gate covering existing tests and visual regressions. Portable agent instructions route contributors through the separate UI standards, repair procedure and forward-merge guide. Existing GitHub workflows are unchanged. diff --git a/release-notes/2026-10-03-request-actions-and-blocklist-preference.md b/release-notes/2026-10-03-request-actions-and-blocklist-preference.md new file mode 100755 index 000000000..ebfba1854 --- /dev/null +++ b/release-notes/2026-10-03-request-actions-and-blocklist-preference.md @@ -0,0 +1,9 @@ +--- +category: fixed +audience: users +area: requests +action: none +breaking: false +--- + +Requesters can again fail a downloading release and start a replacement search from its request card. Discover now respects the hide-blocklisted setting for managers as well as ordinary users, without changing the approved compact controls. diff --git a/release-notes/20261002-native-account-and-card-alignment.md b/release-notes/20261002-native-account-and-card-alignment.md new file mode 100644 index 000000000..ee98563d2 --- /dev/null +++ b/release-notes/20261002-native-account-and-card-alignment.md @@ -0,0 +1,9 @@ +--- +category: fixed +audience: users +area: media-details +action: none +breaking: false +--- + +Native saved actions recognize the signed-in user's linked media account and show specific unavailable-state help. Media Server buttons align left, overview paragraphs are justified, and Discover headings avoid duplicate spacing. diff --git a/release-notes/README.md b/release-notes/README.md index 6e4c4737e..aaf8d70db 100644 --- a/release-notes/README.md +++ b/release-notes/README.md @@ -14,6 +14,7 @@ area: bookshelf action: none breaking: false --- + Hardcover-backed book searches now keep working from cached metadata during a short upstream outage. ``` diff --git a/release-notes/request-retry-history.md b/release-notes/request-retry-history.md new file mode 100644 index 000000000..83ee5baa0 --- /dev/null +++ b/release-notes/request-retry-history.md @@ -0,0 +1,9 @@ +--- +category: fixed +audience: users +area: requests +action: none +breaking: false +--- + +Retrying a failed request now records a waiting-for-dispatch history entry instead of leaving stale download progress in its latest visible status. diff --git a/release-notes/series-overview-disclosure.md b/release-notes/series-overview-disclosure.md new file mode 100644 index 000000000..48f40bc7d --- /dev/null +++ b/release-notes/series-overview-disclosure.md @@ -0,0 +1,9 @@ +--- +category: changed +audience: users +area: series +action: none +breaking: false +--- + +Series Overview now expands below a pinnable, reorderable button row and defaults to the left. Metadata sources and production credits are grouped at the bottom of Details. diff --git a/release-notes/series-request-entry.md b/release-notes/series-request-entry.md new file mode 100644 index 000000000..fa4e2fe93 --- /dev/null +++ b/release-notes/series-request-entry.md @@ -0,0 +1,9 @@ +--- +category: changed +audience: users +area: series +action: none +breaking: false +--- + +Series Details now opens the request screen from one Request button, with HD and 4K selection available inside the screen. diff --git a/release-notes/series-shared-style-cleanup.md b/release-notes/series-shared-style-cleanup.md new file mode 100644 index 000000000..03ae94ee2 --- /dev/null +++ b/release-notes/series-shared-style-cleanup.md @@ -0,0 +1,9 @@ +--- +category: fixed +audience: users +area: interface +action: none +breaking: false +--- + +Series request summaries, advanced options and quality controls now reuse shared interface styling, keeping compact dropdowns, table text and card alignment consistent. diff --git a/release-notes/temporary-upstream-validation.md b/release-notes/temporary-upstream-validation.md new file mode 100644 index 000000000..572d74b3e --- /dev/null +++ b/release-notes/temporary-upstream-validation.md @@ -0,0 +1,9 @@ +--- +category: changed +audience: operators +area: development +action: none +breaking: false +--- + +Ordinary commands retain upstream validation. Builds still check translations and approved visual contracts. The comprehensive runner remains available explicitly, instead of repeating every test during builds and commits. Commit-message checks use the pinned package manager to avoid an incompatible bundled npm launcher. diff --git a/scripts/build-release-assets.test.mjs b/scripts/build-release-assets.test.mjs index d861f3d70..c8c746d66 100644 --- a/scripts/build-release-assets.test.mjs +++ b/scripts/build-release-assets.test.mjs @@ -176,22 +176,31 @@ describe('release asset construction', () => { '#!/bin/sh\nprintf \'%s\\n%s\\n%s\\n\' "$PWD" "$CONFIG_DIRECTORY" "$*" >"$NODE_INVOCATION"\n', { mode: 0o755 } ); - const launch = await new Promise((resolve) => { - const child = spawn(path.join(root, 'seerrng'), ['--version'], { - cwd: fixture.root, - env: { - ...process.env, - NODE_INVOCATION: invocation, - PATH: `${fixture.executableDirectory}:${process.env.PATH}`, - }, + const launch = async (configurationDirectory) => + new Promise((resolve) => { + const child = spawn(path.join(root, 'seerrng'), ['--version'], { + cwd: fixture.root, + env: { + ...process.env, + // Default-path coverage must not inherit the gate's isolated config. + CONFIG_DIRECTORY: configurationDirectory, + NODE_INVOCATION: invocation, + PATH: `${fixture.executableDirectory}:${process.env.PATH}`, + }, + }); + child.on('close', resolve); }); - child.on('close', resolve); - }); - assert.equal(launch, 0); + assert.equal(await launch(''), 0); assert.deepEqual( (await fs.readFile(invocation, 'utf8')).trim().split('\n'), [root, path.join(root, 'config'), 'dist/index.js --version'] ); + const configured = path.join(fixture.root, 'explicit-config'); + assert.equal(await launch(configured), 0); + assert.deepEqual( + (await fs.readFile(invocation, 'utf8')).trim().split('\n'), + [root, configured, 'dist/index.js --version'] + ); }); it('rejects tag traversal before creating a distribution directory', async () => { diff --git a/seerr-api.yml b/seerr-api.yml index 14e5d4243..d31faca49 100644 --- a/seerr-api.yml +++ b/seerr-api.yml @@ -1198,6 +1198,18 @@ components: type: boolean description: 'Keep Subject Tags open by default on supported detail pages' example: false + detailDisclosureOrder: + type: object + additionalProperties: false + description: Independent per-media disclosure order for this user. + properties: + tv: + type: array + uniqueItems: true + maxItems: 6 + items: + type: string + enum: [cast, crew, subjectTags, details, mediaServer, overview] CardTextVisibilitySettings: type: object properties: @@ -9814,6 +9826,79 @@ paths: application/json: schema: $ref: '#/components/schemas/DetailDisclosurePinSettings' + /user/{id}/settings/detail-disclosure-order/{mediaType}: + parameters: + - in: path + name: id + required: true + schema: + type: integer + minimum: 1 + - in: path + name: mediaType + required: true + schema: + type: string + enum: [tv] + get: + summary: Get the current user's Series detail disclosure order + description: Self-only, including administrators. Returns all six roles in normalized order. Overview defaults first when absent; explicitly saved placement is preserved. + tags: [users] + responses: + '200': + description: Normalized disclosure order + content: + application/json: + schema: &detailDisclosureOrder + type: array + minItems: 6 + maxItems: 6 + uniqueItems: true + items: + type: string + enum: + [cast, crew, subjectTags, details, mediaServer, overview] + '400': + description: Invalid media category + '403': + description: Not the current authenticated user + '404': + description: Invalid or nonexistent user + post: + summary: Save the current user's Series detail disclosure order + description: Self-only authenticated operation, protected by the application's cookie and CSRF middleware. Overview defaults first when absent; other missing roles are appended in their default order. Explicitly saved placements and the relative order of existing roles are preserved. + tags: [users] + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + required: [order] + properties: + order: + type: array + maxItems: 6 + uniqueItems: true + items: + type: string + enum: + [cast, crew, subjectTags, details, mediaServer, overview] + responses: + '200': + description: Saved normalized disclosure order + content: + application/json: + schema: *detailDisclosureOrder + '400': + description: Invalid media category or order + '403': + description: Not the current authenticated user + '404': + description: Invalid or nonexistent user + '500': + description: Disclosure order could not be saved /user/{userId}/settings/media-filter-pins/{scope}: post: summary: Remember or unpin a media filter selection for one page @@ -9855,6 +9940,9 @@ paths: magazine, author, software, + retro, + modern, + game, null, ] responses: @@ -13247,6 +13335,12 @@ paths: enum: [movie, tv, music, book, comic, magazine, all] nullable: true default: all + - in: query + name: category + description: Restrict results to one software request category. + schema: + type: string + enum: [retro, modern, game] responses: '200': description: Requests returned @@ -14788,6 +14882,271 @@ paths: application/json: schema: $ref: '#/components/schemas/TvDetails' + /tv/{tvId}/media-server-saved-item: + parameters: + - in: path + name: tvId + required: true + schema: + type: integer + minimum: 1 + maximum: 1000000000 + get: + summary: Get the current account's native Series saved state + description: Reads Plex Universal Watchlist or Jellyfin/Emby Favorites for the authenticated user's linked native account. Favorites use the trusted Series item for the selected quality, without falling back to a different quality. Plex Watchlist is format-independent. Does not mutate provider state or the Seerr watchlist. Provider credentials and catalog identities are resolved server-side and never returned. + tags: [tv] + parameters: + - in: query + name: is4k + schema: + type: boolean + default: false + description: Select the trusted 4K Series root for native Favorites. Accepts only true or false. Ignored by format-independent Plex Watchlist. + security: + - cookieAuth: [] + responses: + '200': + description: Native account capability and saved state + content: + application/json: + schema: &mediaServerSavedItemStatus + type: object + required: [serverType, kind, available] + properties: + serverType: + type: integer + enum: [1, 2, 3, 4] + kind: + type: string + enum: [watchlist, favorites] + available: + type: boolean + saved: + type: boolean + reason: + type: string + enum: [account-not-linked, series-not-found] + '400': + description: Invalid Series ID + '401': + description: Authentication required + '403': + description: Current account authority changed + '429': + description: Too many saved-state requests + '502': + description: Sanitized provider failure; no saved success is asserted + content: + application/json: + schema: &mediaServerSavedItemFailure + type: object + additionalProperties: false + required: [message] + properties: + message: + type: string + diagnostic: + type: object + additionalProperties: false + required: [phase, category] + properties: + phase: + type: string + enum: + [ + series-metadata, + account, + catalog-search, + catalog-identity, + watchlist-state, + watchlist-write, + watchlist-confirmation, + ] + category: + type: string + enum: + [ + http, + transport, + invalid-response, + identity-mismatch, + confirmation-mismatch, + unexpected, + ] + upstreamStatus: + type: integer + minimum: 400 + maximum: 599 + post: + summary: Set the current account's native Series saved state + description: Adds or removes the entire Series from Plex Universal Watchlist or Jellyfin/Emby Favorites using only the authenticated user's fresh linked credentials. Strict desired-state mutation is idempotent and verified by provider readback. Selected seasons and episodes are not added separately. Does not create Seerr requests, change auto-request settings, or move/synchronize membership with any other list. Existing Plex Watchlist auto-request behavior may independently observe membership. Uses application cookie authentication and existing CSRF protection. + tags: [tv] + security: + - cookieAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + required: [saved] + properties: + saved: + type: boolean + is4k: + type: boolean + default: false + description: Select the trusted 4K Series root for Favorites, without other-quality fallback. Plex Watchlist is format-independent. + responses: + '200': + description: Confirmed native saved state + content: + application/json: + schema: *mediaServerSavedItemStatus + '400': + description: Invalid Series ID or mutation body + '401': + description: Authentication required + '403': + description: Current account authority changed or CSRF rejection + '409': + description: Linked account or matching native Series is unavailable + '429': + description: Too many saved-state requests + '502': + description: Sanitized provider failure or unconfirmed write + content: + application/json: + schema: *mediaServerSavedItemFailure + /tv/{tvId}/media-server-collections: + get: + summary: Get existing authorized media-server Collections for this Series + description: Lists existing eligible Collections and whole-Series membership using only the authenticated user's linked native credentials. Collections may be shared server/library assets, not private watchlists. Uses a trusted quality-selected Series root. Plex requires current-account ownership of the configured PMS; Jellyfin/Emby require native Collection-management permission. Smart Collections and unsupported media types are excluded. No Collection is created, and no membership is synchronized with Watchlist or Favorites. + tags: [tv] + security: + - cookieAuth: [] + parameters: + - in: path + name: tvId + required: true + schema: + type: integer + minimum: 1 + maximum: 1000000000 + - in: query + name: is4k + schema: + type: boolean + default: false + description: Select the trusted 4K Series root. Accepts only true or false; no other-quality fallback. + responses: + '200': + description: Native Collection capability and confirmed existing membership + content: + application/json: + schema: &mediaServerCollectionsStatus + type: object + required: [serverType, available, collections] + properties: + serverType: + type: integer + enum: [1, 2, 3, 4] + available: + type: boolean + reason: + type: string + enum: + [ + account-not-linked, + not-authorized, + series-not-found, + unsupported-server, + collection-limit, + ] + collections: + type: array + items: + type: object + required: [id, name, member] + properties: + id: + type: string + minLength: 1 + maxLength: 128 + pattern: '^[a-zA-Z0-9-]+$' + name: + type: string + minLength: 1 + maxLength: 512 + member: + type: boolean + '400': + description: Invalid Series ID or strict quality query + '401': + description: Authentication required + '403': + description: Current account authority changed + '429': + description: Too many native Collection requests + '502': + description: Sanitized provider failure or incomplete native response + /tv/{tvId}/media-server-collections/{collectionId}: + post: + summary: Set whole-Series membership in one existing authorized Collection + description: Idempotently adds or removes the trusted quality-selected Series root in an existing eligible media-server Collection using fresh current-user native authority. Revalidates Collection and Series identities and verifies membership after any write. Does not create or delete a Collection, add selected episodes, synchronize other list membership, or directly create Seerr requests. Uses application cookie authentication and existing CSRF protection. Tokens, native Series item IDs and user IDs are never client parameters. + tags: [tv] + security: + - cookieAuth: [] + parameters: + - in: path + name: tvId + required: true + schema: + type: integer + minimum: 1 + maximum: 1000000000 + - in: path + name: collectionId + required: true + schema: + type: string + minLength: 1 + maxLength: 128 + pattern: '^[a-zA-Z0-9-]+$' + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + required: [member] + properties: + member: + type: boolean + is4k: + type: boolean + default: false + description: Select the trusted 4K Series root, without other-quality fallback. + responses: + '200': + description: Refreshed native Collection membership + content: + application/json: + schema: *mediaServerCollectionsStatus + '400': + description: Invalid Series or Collection ID, unexpected query, or mutation body + '401': + description: Authentication required + '403': + description: Current account authority changed, native permission denied, or CSRF rejection + '409': + description: Linked account, selected-quality Series, or eligible Collection is unavailable + '429': + description: Too many native Collection requests + '502': + description: Sanitized provider failure or unconfirmed write /tv/{tvId}/cover: get: summary: Get linked Sonarr cover image diff --git a/server/api/jellyfinEmbySavedItems.test.ts b/server/api/jellyfinEmbySavedItems.test.ts new file mode 100644 index 000000000..220b54e28 --- /dev/null +++ b/server/api/jellyfinEmbySavedItems.test.ts @@ -0,0 +1,343 @@ +import { MediaServerType } from '@server/constants/server'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + FavoriteSeriesAPI, + getJellyfinEmbySeriesCollections, +} from './jellyfinEmbySavedItems'; + +const state = vi.hoisted(() => ({ + request: vi.fn(), + construction: vi.fn(), + settings: { main: { mediaServerType: 2 }, jellyfin: {} }, +})); +vi.mock('@server/api/externalapi', () => ({ + default: class { + constructor(...args: unknown[]) { + state.construction(...args); + } + request(...args: unknown[]) { + return state.request(...args); + } + }, +})); +vi.mock('@server/lib/settings', () => ({ getSettings: () => state.settings })); +vi.mock('@server/utils/getHostname', () => ({ + getHostname: () => 'http://configured-server:8096', +})); + +const userId = '11111111111141118111111111111111'; +const profile = (permission = true) => ({ + Id: userId, + Policy: { + IsAdministrator: permission, + EnableCollectionManagement: permission, + IsDisabled: false, + }, +}); +const series = (saved = false) => ({ + Id: 'series', + Type: 'Series', + ProviderIds: { Tmdb: '113962' }, + UserData: { IsFavorite: saved }, +}); +const client = (type = MediaServerType.JELLYFIN, trustedAccountPair = true) => + new FavoriteSeriesAPI( + 'http://configured-server:8096', + 'own-user-token', + userId, + { serverType: type, trustedAccountPair } + ); +const collectionsPage = ( + items: unknown[] = [{ Id: 'collection', Type: 'BoxSet', Name: 'Watch Next' }], + total = items.length, + offset = 0 +) => ({ Items: items, TotalRecordCount: total, StartIndex: offset }); + +beforeEach(() => { + state.request.mockReset(); + state.construction.mockClear(); + state.settings.main.mediaServerType = MediaServerType.JELLYFIN; +}); + +describe.each([MediaServerType.JELLYFIN, MediaServerType.EMBY])( + 'native saved actions %s', + (type) => { + it('uses only the linked user token, never service API key', async () => { + const api = client(type); + state.request.mockResolvedValue({ data: profile() }); + await api.verifyAccount(); + expect(state.construction.mock.calls[0][2].headers).toEqual({ + 'X-Emby-Token': 'own-user-token', + Accept: 'application/json', + }); + expect(state.request.mock.calls[0][1]).toBe( + type === MediaServerType.JELLYFIN ? '/Users/Me' : `/Users/${userId}` + ); + }); + it('rejects a mismatched or disabled account', async () => { + const api = client(type); + state.request.mockResolvedValue({ data: { ...profile(), Id: 'other' } }); + await expect(api.verifyAccount()).rejects.toThrow('not-authorized'); + state.request.mockResolvedValue({ + data: { ...profile(), Policy: { IsDisabled: true } }, + }); + await expect(api.verifyAccount()).rejects.toThrow('not-authorized'); + }); + it('rejects wrong source or non-Series before any write', async () => { + const api = client(type); + state.request.mockImplementation(async (_method, path) => ({ + data: path.includes('/Items/') + ? { ...series(), ProviderIds: { Tmdb: '999' } } + : profile(), + })); + await expect(api.getSeriesSaved('series', 113962)).rejects.toThrow( + 'series-not-found' + ); + await expect(api.setSaved('series', true)).rejects.toThrow( + 'series-not-found' + ); + expect( + state.request.mock.calls.every(([method]) => method === 'GET') + ).toBe(true); + }); + it('writes only the verified whole-Series FavoriteItems route and confirms fresh state', async () => { + const api = client(type); + let saved = false; + state.request.mockImplementation(async (method, path) => { + if (method === 'POST') { + saved = true; + return { data: { IsFavorite: true } }; + } + return { data: path.includes('/Items/') ? series(saved) : profile() }; + }); + await api.getSeriesSaved('series', 113962); + expect(await api.setSaved('series', true)).toBe(true); + const writes = state.request.mock.calls.filter( + ([method]) => method !== 'GET' + ); + expect(writes).toEqual([ + ['POST', `/Users/${userId}/FavoriteItems/series`], + ]); + expect(state.request.mock.calls.at(-1)?.[1]).toBe( + `/Users/${userId}/Items/series` + ); + }); + it('blocks collections without current native permission while Favorites remain usable', async () => { + const api = client(type); + state.request.mockImplementation(async (_method, path) => ({ + data: path.includes('/Items/') ? series() : profile(false), + })); + expect(await api.getSeriesSaved('series', 113962)).toBe(false); + await expect(api.seriesCollections('series', 113962)).rejects.toThrow( + 'not-authorized' + ); + expect( + state.request.mock.calls.every(([method]) => method === 'GET') + ).toBe(true); + }); + it('lists existing accessible collections and reads membership under own user', async () => { + const api = client(type); + state.request.mockImplementation(async (_method, path, _body, config) => { + const data = path.endsWith('/Items/series') + ? series() + : path.endsWith('/Items/collection') + ? { Id: 'collection', Type: 'BoxSet' } + : path.endsWith('/Items') + ? config.params.ParentId + ? collectionsPage([series()]) + : collectionsPage() + : profile(); + return { data }; + }); + expect(await api.seriesCollections('series', 113962)).toEqual([ + { id: 'collection', name: 'Watch Next', member: true }, + ]); + const queries = state.request.mock.calls.filter(([, path]) => + path.endsWith('/Items') + ); + expect( + queries.every(([, path]) => path === `/Users/${userId}/Items`) + ).toBe(true); + expect(queries[1][3].params).toMatchObject({ + ParentId: 'collection', + Ids: 'series', + Recursive: false, + }); + }); + it('adds/removes membership without deleting collections or files', async () => { + const api = client(type); + let member = false; + state.request.mockImplementation(async (method, path, _body, config) => { + if (method !== 'GET') { + member = method === 'POST'; + return { data: {} }; + } + const data = path.endsWith('/Items/series') + ? series() + : path.endsWith('/Items/collection') + ? { Id: 'collection', Type: 'BoxSet' } + : path.endsWith('/Items') + ? config.params.ParentId + ? collectionsPage(member ? [series()] : []) + : collectionsPage() + : profile(); + return { data }; + }); + expect( + ( + await api.setCollectionMembership( + 'series', + 113962, + 'collection', + true + ) + )[0].member + ).toBe(true); + expect( + ( + await api.setCollectionMembership( + 'series', + 113962, + 'collection', + false + ) + )[0].member + ).toBe(false); + expect( + state.request.mock.calls.filter(([method]) => method !== 'GET') + ).toEqual([ + [ + 'POST', + '/Collections/collection/Items', + undefined, + { params: { Ids: 'series' } }, + ], + [ + 'DELETE', + '/Collections/collection/Items', + undefined, + { params: { Ids: 'series' } }, + ], + ]); + }); + } +); + +it('requires explicit trusted persisted login-pair provenance on Emby, not a spoofed device/user ID', async () => { + await expect( + client(MediaServerType.EMBY, false).verifyAccount() + ).rejects.toThrow('account-not-linked'); + expect(state.request).not.toHaveBeenCalled(); +}); +it('rejects malformed path IDs and missing/invalid source IDs before reads', async () => { + const api = client(); + await expect(api.getSeriesSaved('../series', 113962)).rejects.toThrow(); + await expect(api.getSeriesSaved('series', NaN)).rejects.toThrow(); + await expect( + api.setCollectionMembership('series', 113962, '../collection', true) + ).rejects.toThrow(); + expect(state.request).not.toHaveBeenCalled(); +}); +it('bounds collection loading and rejects pagination drift/duplicates', async () => { + const api = client(); + state.request.mockImplementation(async (_method, path) => ({ + data: path.endsWith('/Items/series') + ? series() + : path.endsWith('/Items') + ? collectionsPage([], 201) + : profile(), + })); + await expect(api.seriesCollections('series', 113962)).rejects.toThrow( + 'collection-limit' + ); + state.request.mockImplementation(async (_method, path) => ({ + data: path.endsWith('/Items/series') + ? series() + : path.endsWith('/Items/collection') + ? { Id: 'collection', Type: 'BoxSet' } + : path.endsWith('/Items') + ? collectionsPage([ + { Id: 'collection', Type: 'BoxSet', Name: 'A' }, + { Id: 'collection', Type: 'BoxSet', Name: 'B' }, + ]) + : profile(), + })); + await expect(api.seriesCollections('series', 113962)).rejects.toThrow(); +}); +it('loads bounded consecutive pages without dropping or duplicating collection options', async () => { + const api = client(); + const makeCollection = (index: number) => ({ + Id: `c${index}`, + Type: 'BoxSet', + Name: `List ${index}`, + }); + state.request.mockImplementation(async (_method, path, _body, config) => ({ + data: path.endsWith('/Items/series') + ? series() + : path.endsWith('/Items') + ? config.params.ParentId + ? collectionsPage([]) + : config.params.StartIndex === 0 + ? collectionsPage( + Array.from({ length: 50 }, (_, index) => makeCollection(index)), + 51 + ) + : collectionsPage([makeCollection(50)], 51, 50) + : profile(), + })); + expect( + (await api.seriesCollections('series', 113962)).map((item) => item.id) + ).toEqual(Array.from({ length: 51 }, (_, index) => `c${index}`)); + expect( + state.request.mock.calls + .filter( + ([, path, , config]) => + path.endsWith('/Items') && !config.params.ParentId + ) + .map(([, , , config]) => config.params.StartIndex) + ).toEqual([0, 50]); +}); + +it('rejects fabricated membership responses and unconfirmed favorite writes', async () => { + const api = client(); + state.request.mockImplementation(async (method, path) => ({ + data: + method === 'POST' + ? { IsFavorite: true } + : path.endsWith('/Items/series') + ? series(false) + : profile(), + })); + await api.getSeriesSaved('series', 113962); + await expect(api.setSaved('series', true)).rejects.toThrow( + 'could not be verified' + ); + state.request.mockImplementation(async (_method, path, _body, config) => ({ + data: path.endsWith('/Items/series') + ? series() + : path.endsWith('/Items') + ? config.params.ParentId + ? collectionsPage([{ ...series(), Id: 'other' }]) + : collectionsPage() + : profile(), + })); + await expect(api.seriesCollections('series', 113962)).rejects.toThrow( + 'membership could not be verified' + ); +}); + +it('returns clear unavailable state without exposing credentials or borrowing admin tokens', async () => { + const context = { + user: { id: 2, jellyfinUserId: userId }, + tmdbId: 113962, + itemId: 'series', + is4k: false, + }; + expect(await getJellyfinEmbySeriesCollections(context)).toEqual({ + serverType: MediaServerType.JELLYFIN, + available: false, + reason: 'account-not-linked', + collections: [], + }); + expect(state.request).not.toHaveBeenCalled(); +}); diff --git a/server/api/jellyfinEmbySavedItems.ts b/server/api/jellyfinEmbySavedItems.ts new file mode 100644 index 000000000..2b964077c --- /dev/null +++ b/server/api/jellyfinEmbySavedItems.ts @@ -0,0 +1,353 @@ +import ExternalAPI from '@server/api/externalapi'; +import { MediaServerType } from '@server/constants/server'; +import { getSettings } from '@server/lib/settings'; +import type { + MediaServerCollectionContext, + MediaServerCollectionOption, + MediaServerCollectionsStatus, +} from '@server/models/MediaServerCollections'; +import { getHostname } from '@server/utils/getHostname'; + +const record = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value); +export const validSavedItemId = (value: unknown): value is string => + typeof value === 'string' && /^[a-z\d-]{1,128}$/i.test(value); +const sameId = (left: unknown, right: string): boolean => + validSavedItemId(left) && + (left === right || + (/^[a-f\d]{32}$/i.test(left.replace(/-/g, '')) && + /^[a-f\d]{32}$/i.test(right.replace(/-/g, '')) && + left.replace(/-/g, '').toLowerCase() === + right.replace(/-/g, '').toLowerCase())); +const validTmdbId = (value: number): boolean => + Number.isSafeInteger(value) && value > 0; + +class SavedItemError extends Error { + constructor( + readonly reason: NonNullable + ) { + super(`Media-server action unavailable: ${reason}.`); + } +} + +/** Native account Favorites and shared collections. No service-owner token fallback. */ +export class FavoriteSeriesAPI extends ExternalAPI { + private readonly serverType: MediaServerType; + private readonly trustedAccountPair: boolean; + private readonly verifiedSeries = new Map(); + + constructor( + host: string, + token: string, + private readonly userId: string, + options: { + serverType?: MediaServerType; + trustedAccountPair?: boolean; + } = {} + ) { + if (!validSavedItemId(userId) || !token || /[\r\n]/.test(token)) + throw new SavedItemError('account-not-linked'); + super( + host, + {}, + { + allowPrivateAddresses: true, + timeout: 8000, + maxContentLength: 2 * 1024 * 1024, + headers: { 'X-Emby-Token': token, Accept: 'application/json' }, + } + ); + this.serverType = options.serverType ?? getSettings().main.mediaServerType; + this.trustedAccountPair = options.trustedAccountPair === true; + } + + private async profile(): Promise> { + let data: unknown; + if (this.serverType === MediaServerType.JELLYFIN) { + ({ data } = await this.request('GET', '/Users/Me')); + } else if (this.serverType === MediaServerType.EMBY) { + // Emby documents no token-self endpoint. Only accept the server-owned + // User.Id + AccessToken pair persisted from the SAME native login result + // by Seerr's authenticated linking/login flow. Users/{Id} alone is not + // proof of token ownership; a client-supplied ID or DeviceId cannot attest it. + if (!this.trustedAccountPair) + throw new SavedItemError('account-not-linked'); + ({ data } = await this.request('GET', `/Users/${this.userId}`)); + } else { + throw new SavedItemError('unsupported-server'); + } + if ( + !record(data) || + !sameId(data.Id, this.userId) || + !record(data.Policy) || + data.Policy.IsDisabled === true + ) + throw new SavedItemError('not-authorized'); + return data; + } + + public async verifyAccount(): Promise { + await this.profile(); + } + + public async getSeriesSaved( + itemId: string, + tmdbId: number + ): Promise { + if (!validSavedItemId(itemId) || !validTmdbId(tmdbId)) + throw new SavedItemError('series-not-found'); + this.verifiedSeries.delete(itemId); + await this.verifyAccount(); + const { data } = await this.request( + 'GET', + `/Users/${this.userId}/Items/${itemId}`, + undefined, + { params: { Fields: 'ProviderIds' } } + ); + if ( + !record(data) || + !sameId(data.Id, itemId) || + data.Type !== 'Series' || + !record(data.ProviderIds) || + (data.ProviderIds.Tmdb ?? data.ProviderIds.TheMovieDb) !== + String(tmdbId) || + !record(data.UserData) || + typeof data.UserData.IsFavorite !== 'boolean' || + data.LocationType === 'Virtual' || + data.LocationType === 'Offline' + ) + throw new SavedItemError('series-not-found'); + this.verifiedSeries.set(itemId, tmdbId); + return data.UserData.IsFavorite; + } + + public async setSaved(itemId: string, saved: boolean): Promise { + const tmdbId = this.verifiedSeries.get(itemId); + if (!tmdbId || typeof saved !== 'boolean') + throw new SavedItemError('series-not-found'); + const current = await this.getSeriesSaved(itemId, tmdbId); + if (current !== saved) { + const { data } = await this.request( + saved ? 'POST' : 'DELETE', + `/Users/${this.userId}/FavoriteItems/${itemId}` + ); + if (!record(data) || data.IsFavorite !== saved) + throw new Error('Favorites update could not be verified.'); + } + const confirmed = await this.getSeriesSaved(itemId, tmdbId); + if (confirmed !== saved) + throw new Error('Favorites update could not be verified.'); + return confirmed; + } + + private async verifyCollectionManagement(): Promise { + const profile = await this.profile(); + const policy = profile.Policy as Record; + // Jellyfin's controller uses EnableCollectionManagement. Emby's documented + // policy has no equivalent field: permit verified native administrators only + // until a finer current permission contract is established. + if ( + this.serverType === MediaServerType.JELLYFIN + ? policy.EnableCollectionManagement !== true + : policy.IsAdministrator !== true + ) + throw new SavedItemError('not-authorized'); + } + + private async collectionMember( + collectionId: string, + itemId: string, + verifyCollection = true + ): Promise { + if (verifyCollection) { + const { data: collection } = await this.request( + 'GET', + `/Users/${this.userId}/Items/${collectionId}` + ); + if ( + !record(collection) || + !sameId(collection.Id, collectionId) || + collection.Type !== 'BoxSet' + ) + throw new SavedItemError('not-authorized'); + } + const { data } = await this.request( + 'GET', + `/Users/${this.userId}/Items`, + undefined, + { + params: { + ParentId: collectionId, + Ids: itemId, + Recursive: false, + CollapseBoxSetItems: false, + Limit: 2, + }, + } + ); + if ( + !record(data) || + !Array.isArray(data.Items) || + !Number.isSafeInteger(data.TotalRecordCount) || + data.TotalRecordCount !== data.Items.length || + data.Items.length > 1 || + data.Items.some( + (item) => + !record(item) || !sameId(item.Id, itemId) || item.Type !== 'Series' + ) + ) + throw new Error('Collection membership could not be verified.'); + return data.Items.length === 1; + } + + public async seriesCollections( + itemId: string, + tmdbId: number + ): Promise { + await this.verifyCollectionManagement(); + await this.getSeriesSaved(itemId, tmdbId); + const options: MediaServerCollectionOption[] = []; + const seen = new Set(); + let total: number | undefined; + for (let offset = 0; offset < 200; offset += 50) { + const { data } = await this.request( + 'GET', + `/Users/${this.userId}/Items`, + undefined, + { + params: { + IncludeItemTypes: 'BoxSet', + Recursive: true, + SortBy: 'SortName', + SortOrder: 'Ascending', + StartIndex: offset, + Limit: 50, + }, + } + ); + if ( + !record(data) || + !Array.isArray(data.Items) || + !Number.isSafeInteger(data.TotalRecordCount) || + Number(data.TotalRecordCount) < 0 || + data.StartIndex !== offset || + data.Items.length > 50 + ) + throw new Error('Collection list could not be verified.'); + if (Number(data.TotalRecordCount) > 200) + throw new SavedItemError('collection-limit'); + if (total !== undefined && total !== data.TotalRecordCount) + throw new Error('Collection list changed during loading.'); + total = Number(data.TotalRecordCount); + for (const raw of data.Items) { + if ( + !record(raw) || + raw.Type !== 'BoxSet' || + !validSavedItemId(raw.Id) || + typeof raw.Name !== 'string' || + !raw.Name.trim() || + raw.Name.length > 512 || + seen.has(raw.Id) + ) + throw new Error('Collection identity could not be verified.'); + seen.add(raw.Id); + options.push({ + id: raw.Id, + name: raw.Name, + // This fresh user-scoped page already verified the BoxSet identity. + // Only writes need the additional direct collection identity read. + member: await this.collectionMember(raw.Id, itemId, false), + }); + } + if (options.length === total) return options; + if (options.length > total || data.Items.length !== 50) + throw new Error('Incomplete collection list.'); + } + throw new SavedItemError('collection-limit'); + } + + public async setCollectionMembership( + itemId: string, + tmdbId: number, + collectionId: string, + member: boolean + ): Promise { + if (!validSavedItemId(collectionId) || typeof member !== 'boolean') + throw new SavedItemError('not-authorized'); + const options = await this.seriesCollections(itemId, tmdbId); + const selected = options.find((option) => option.id === collectionId); + if (!selected) throw new SavedItemError('not-authorized'); + if (selected.member !== member) { + // Revalidate capability and identities immediately before the native write. + await this.verifyCollectionManagement(); + await this.getSeriesSaved(itemId, tmdbId); + await this.collectionMember(collectionId, itemId); + await this.request( + member ? 'POST' : 'DELETE', + `/Collections/${collectionId}/Items`, + undefined, + { params: { Ids: itemId } } + ); + if ((await this.collectionMember(collectionId, itemId)) !== member) + throw new Error('Collection update could not be verified.'); + } + return this.seriesCollections(itemId, tmdbId); + } +} + +const collectionClient = ( + context: MediaServerCollectionContext +): FavoriteSeriesAPI => { + const settings = getSettings(); + if ( + ![MediaServerType.JELLYFIN, MediaServerType.EMBY].includes( + settings.main.mediaServerType + ) + ) + throw new SavedItemError('unsupported-server'); + if (!context.user.jellyfinAuthToken || !context.user.jellyfinUserId) + throw new SavedItemError('account-not-linked'); + return new FavoriteSeriesAPI( + getHostname(settings.jellyfin), + context.user.jellyfinAuthToken, + context.user.jellyfinUserId, + { serverType: settings.main.mediaServerType, trustedAccountPair: true } + ); +}; + +export const getJellyfinEmbySeriesCollections = async ( + context: MediaServerCollectionContext +): Promise => { + const serverType = getSettings().main.mediaServerType; + try { + const collections = await collectionClient(context).seriesCollections( + context.itemId, + context.tmdbId + ); + return { serverType, available: true, collections }; + } catch (error) { + if (error instanceof SavedItemError) + return { + serverType, + available: false, + reason: error.reason, + collections: [], + }; + throw error; + } +}; + +export const setJellyfinEmbySeriesCollectionMembership = async ( + context: MediaServerCollectionContext, + collectionId: string, + member: boolean +): Promise => ({ + serverType: getSettings().main.mediaServerType, + available: true, + collections: await collectionClient(context).setCollectionMembership( + context.itemId, + context.tmdbId, + collectionId, + member + ), +}); diff --git a/server/api/mediaServerCollections.ts b/server/api/mediaServerCollections.ts new file mode 100644 index 000000000..332e84b93 --- /dev/null +++ b/server/api/mediaServerCollections.ts @@ -0,0 +1,96 @@ +import { + getJellyfinEmbySeriesCollections, + setJellyfinEmbySeriesCollectionMembership, +} from '@server/api/jellyfinEmbySavedItems'; +import { + getPlexSeriesCollections, + setPlexSeriesCollectionMembership, +} from '@server/api/plexCollections'; +import { MediaServerType } from '@server/constants/server'; +import { getSettings } from '@server/lib/settings'; +import type { + MediaServerCollectionContext, + MediaServerCollectionsStatus, +} from '@server/models/MediaServerCollections'; + +export const isMediaServerCollectionId = ( + serverType: MediaServerType, + value: unknown +): value is string => { + if (typeof value !== 'string') return false; + const numeric = /^[1-9]\d{0,19}$/.test(value); + const guid = + /^[a-f\d]{32}$/i.test(value) || + /^[a-f\d]{8}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{12}$/i.test(value); + if (serverType === MediaServerType.PLEX) return numeric; + if (serverType === MediaServerType.JELLYFIN) return guid; + if (serverType === MediaServerType.EMBY) return numeric || guid; + return false; +}; + +const unavailable = ( + serverType: MediaServerType, + reason: NonNullable +): MediaServerCollectionsStatus => ({ + serverType, + available: false, + reason, + collections: [], +}); + +const contextUnavailable = ( + context: MediaServerCollectionContext, + serverType: MediaServerType +): MediaServerCollectionsStatus | undefined => { + if ( + ![ + MediaServerType.PLEX, + MediaServerType.JELLYFIN, + MediaServerType.EMBY, + ].includes(serverType) + ) + return unavailable(serverType, 'unsupported-server'); + if ( + serverType === MediaServerType.PLEX + ? !context.user.plexToken || !context.user.plexId + : !context.user.jellyfinAuthToken || !context.user.jellyfinUserId + ) + return unavailable(serverType, 'account-not-linked'); + if ( + !Number.isSafeInteger(context.tmdbId) || + context.tmdbId <= 0 || + !isMediaServerCollectionId(serverType, context.itemId) + ) + return unavailable(serverType, 'series-not-found'); + return undefined; +}; + +/** Context is resolved under application account authority, never from client IDs/tokens. */ +export const getMediaServerSeriesCollections = async ( + context: MediaServerCollectionContext +): Promise => { + const serverType = getSettings().main.mediaServerType; + const missing = contextUnavailable(context, serverType); + if (missing) return missing; + return serverType === MediaServerType.PLEX + ? getPlexSeriesCollections(context) + : getJellyfinEmbySeriesCollections(context); +}; + +export const setMediaServerSeriesCollectionMembership = async ( + context: MediaServerCollectionContext, + collectionId: string, + member: boolean +): Promise => { + const serverType = getSettings().main.mediaServerType; + const missing = contextUnavailable(context, serverType); + if (missing) return missing; + if ( + !isMediaServerCollectionId(serverType, collectionId) || + typeof member !== 'boolean' + ) + return unavailable(serverType, 'not-authorized'); + return serverType === MediaServerType.PLEX + ? setPlexSeriesCollectionMembership(context, collectionId, member) + : setJellyfinEmbySeriesCollectionMembership(context, collectionId, member); +}; diff --git a/server/api/mediaServerSavedItem.ts b/server/api/mediaServerSavedItem.ts new file mode 100644 index 000000000..d0e3283ed --- /dev/null +++ b/server/api/mediaServerSavedItem.ts @@ -0,0 +1,318 @@ +import ExternalAPI from '@server/api/externalapi'; +import { PLEXTV_HTTP_OPTIONS, parsePlexDevices } from '@server/api/plextv'; +import { mapWithConcurrency } from '@server/utils/concurrency'; +import { isAxiosError } from 'axios'; +import { parseStringPromise } from 'xml2js'; +export { + FavoriteSeriesAPI, + validSavedItemId, +} from '@server/api/jellyfinEmbySavedItems'; + +const DISCOVER = 'https://discover.provider.plex.tv'; +const METADATA = 'https://metadata.provider.plex.tv'; +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const WATCHLIST_PHASES = [ + 'series-metadata', + 'account', + 'catalog-search', + 'catalog-identity', + 'watchlist-state', + 'watchlist-write', + 'watchlist-confirmation', +] as const; +const WATCHLIST_FAILURE_CATEGORIES = [ + 'http', + 'transport', + 'invalid-response', + 'identity-mismatch', + 'confirmation-mismatch', + 'unexpected', +] as const; +type WatchlistPhase = (typeof WATCHLIST_PHASES)[number]; +type WatchlistFailureCategory = (typeof WATCHLIST_FAILURE_CATEGORIES)[number]; +export interface PlexWatchlistDiagnostic { + phase: WatchlistPhase; + category: WatchlistFailureCategory; + upstreamStatus?: number; +} + +// Store only allowlisted diagnostics. Never retain a provider error/cause, +// response body, URL, request configuration, token, or account/media identity. +export class PlexWatchlistError extends Error { + public readonly diagnostic: Readonly; + + constructor( + phase: WatchlistPhase, + category: WatchlistFailureCategory, + upstreamStatus?: number + ) { + super('Plex Watchlist operation failed.'); + this.name = 'PlexWatchlistError'; + this.diagnostic = Object.freeze({ + phase, + category, + ...(category === 'http' && + Number.isInteger(upstreamStatus) && + upstreamStatus! >= 400 && + upstreamStatus! <= 599 + ? { upstreamStatus } + : {}), + }); + } +} + +export const getPlexWatchlistDiagnostic = ( + error: unknown +): PlexWatchlistDiagnostic | undefined => { + if (!(error instanceof PlexWatchlistError)) return undefined; + const { phase, category, upstreamStatus } = error.diagnostic; + if ( + !WATCHLIST_PHASES.includes(phase) || + !WATCHLIST_FAILURE_CATEGORIES.includes(category) + ) + return undefined; + // Copy only known fields even if the error was extended by another caller. + return { + phase, + category, + ...(category === 'http' && + Number.isInteger(upstreamStatus) && + upstreamStatus! >= 400 && + upstreamStatus! <= 599 + ? { upstreamStatus } + : {}), + }; +}; + +export const runPlexWatchlistPhase = async ( + phase: WatchlistPhase, + operation: () => Promise +): Promise => { + try { + return await operation(); + } catch (error) { + if (getPlexWatchlistDiagnostic(error)) throw error; + if (isAxiosError(error)) { + const status = error.response?.status; + throw new PlexWatchlistError( + phase, + Number.isInteger(status) && status! >= 400 && status! <= 599 + ? 'http' + : 'transport', + status + ); + } + throw new PlexWatchlistError(phase, 'unexpected'); + } +}; + +export const plexShowCatalogId = (guid: unknown): string | undefined => + typeof guid === 'string' + ? /^plex:\/\/show\/([a-f\d]{24})$/i.exec(guid)?.[1] + : undefined; + +export const exactPlexSeries = ( + value: unknown, + tmdbId: number +): string | undefined => { + if (!isRecord(value) || value.type !== 'show') return undefined; + const catalogId = plexShowCatalogId(value.guid); + return catalogId && + Array.isArray(value.Guid) && + value.Guid.length <= 100 && + value.Guid.some((guid) => isRecord(guid) && guid.id === `tmdb://${tmdbId}`) + ? catalogId + : undefined; +}; + +export const parsePlexSavedState = ( + value: unknown, + phase: 'watchlist-state' | 'watchlist-confirmation' = 'watchlist-state' +): boolean => { + const container = + isRecord(value) && isRecord(value.MediaContainer) + ? value.MediaContainer + : undefined; + const state = Array.isArray(container?.UserState) + ? container.UserState.length === 1 + ? container.UserState[0] + : undefined + : container?.UserState; + if (!isRecord(state)) throw new PlexWatchlistError(phase, 'invalid-response'); + const timestamp = state.watchlistedAt; + if ( + timestamp === undefined || + timestamp === null || + timestamp === 0 || + timestamp === '0' + ) + return false; + if ( + (typeof timestamp !== 'number' && typeof timestamp !== 'string') || + !/^\d{1,15}$/.test(String(timestamp)) + ) + throw new PlexWatchlistError(phase, 'invalid-response'); + return Number(timestamp) > 0; +}; + +/** Plex's account-level Universal Watchlist, not PMS queues or Seerr's list. */ +export class PlexSavedItemAPI extends ExternalAPI { + constructor(token: string) { + super( + 'https://plex.tv', + {}, + { + ...PLEXTV_HTTP_OPTIONS, + allowedBaseUrls: [DISCOVER, METADATA], + headers: { 'X-Plex-Token': token, Accept: 'application/json' }, + } + ); + } + + public async verifyAccount(plexId: number): Promise { + return runPlexWatchlistPhase('account', async () => { + const { data } = await this.request( + 'GET', + '/users/account.json' + ); + if (!isRecord(data) || !isRecord(data.user)) + throw new PlexWatchlistError('account', 'invalid-response'); + if (Number(data.user.id) !== plexId) + throw new PlexWatchlistError('account', 'identity-mismatch'); + }); + } + + public async getOwnedServerIds(): Promise { + // Reuse the bounded resources parser without legacy error logging/cache. + // Provider errors can contain account tokens; the route sanitizes failures. + const { data } = await this.request( + 'GET', + '/api/resources?includeHttps=1', + undefined, + { transformResponse: [], responseType: 'text' } + ); + const devices = parsePlexDevices(await parseStringPromise(data)); + return devices + .filter((device) => device.owned && device.provides.includes('server')) + .map((device) => device.clientIdentifier); + } + + public async resolveSeries( + tmdbId: number, + title: string + ): Promise { + const data = await runPlexWatchlistPhase('catalog-search', async () => { + const response = await this.request( + 'GET', + '/library/search', + undefined, + { + baseURL: DISCOVER, + params: { + query: title.slice(0, 512), + limit: 30, + searchTypes: 'tv', + searchProviders: 'discover', + includeMetadata: 1, + }, + } + ); + return response.data; + }); + if ( + !isRecord(data) || + !isRecord(data.MediaContainer) || + !Array.isArray(data.MediaContainer.SearchResults) + ) { + throw new PlexWatchlistError('catalog-search', 'invalid-response'); + } + const external = data.MediaContainer.SearchResults.find( + (group) => isRecord(group) && group.id === 'external' + ); + if (!external) return undefined; + if ( + !isRecord(external) || + !Array.isArray(external.SearchResult) || + external.SearchResult.length > 30 + ) { + throw new PlexWatchlistError('catalog-search', 'invalid-response'); + } + const candidates = [ + ...new Set( + external.SearchResult.flatMap((result) => { + const metadata = isRecord(result) ? result.Metadata : undefined; + const id = + isRecord(metadata) && metadata.type === 'show' + ? plexShowCatalogId(metadata.guid) + : undefined; + return id ? [id] : []; + }) + ), + ]; + const matches = await mapWithConcurrency(candidates, 5, async (id) => { + const metadata = await runPlexWatchlistPhase( + 'catalog-identity', + async () => { + const response = await this.request( + 'GET', + `/library/metadata/${id}`, + undefined, + { baseURL: METADATA, params: { includeGuids: 1 } } + ); + return response.data; + } + ); + if ( + !isRecord(metadata) || + !isRecord(metadata.MediaContainer) || + !Array.isArray(metadata.MediaContainer.Metadata) || + metadata.MediaContainer.Metadata.length !== 1 + ) { + throw new PlexWatchlistError('catalog-identity', 'invalid-response'); + } + const matched = exactPlexSeries( + metadata.MediaContainer.Metadata[0], + tmdbId + ); + return matched === id ? matched : undefined; + }); + const exact = matches.filter((id): id is string => !!id); + if (exact.length > 1) + throw new PlexWatchlistError('catalog-identity', 'identity-mismatch'); + return exact[0]; + } + + public async getSaved( + id: string, + phase: 'watchlist-state' | 'watchlist-confirmation' = 'watchlist-state' + ): Promise { + if (!plexShowCatalogId(`plex://show/${id}`)) + throw new Error('Invalid Plex catalog ID.'); + return runPlexWatchlistPhase(phase, async () => { + const { data } = await this.request( + 'GET', + `/library/metadata/${id}/userState`, + undefined, + { baseURL: METADATA } + ); + return parsePlexSavedState(data, phase); + }); + } + + public async setSaved(id: string, saved: boolean): Promise { + if (!plexShowCatalogId(`plex://show/${id}`)) + throw new Error('Invalid Plex catalog ID.'); + // Contract advertised by Discover / and exercised by python-plexapi MyPlexAccount. + // A cloud Plex show GUID suffix is required; never a local PMS ratingKey. + await runPlexWatchlistPhase('watchlist-write', async () => { + await this.request( + 'PUT', + `/actions/${saved ? 'addToWatchlist' : 'removeFromWatchlist'}`, + undefined, + { baseURL: DISCOVER, params: { ratingKey: id } } + ); + }); + } +} diff --git a/server/api/plexCollections.ts b/server/api/plexCollections.ts new file mode 100644 index 000000000..edda85387 --- /dev/null +++ b/server/api/plexCollections.ts @@ -0,0 +1,371 @@ +import { PlexSavedItemAPI } from '@server/api/mediaServerSavedItem'; +import PlexAPI from '@server/api/plexapi'; +import { MediaServerType } from '@server/constants/server'; +import { getSettings } from '@server/lib/settings'; +import type { + MediaServerCollectionContext, + MediaServerCollectionsStatus, +} from '@server/models/MediaServerCollections'; +import { mapWithConcurrency } from '@server/utils/concurrency'; + +const PAGE_SIZE = 100; +const MAX_COLLECTIONS = 500; +const MAX_MEMBERS = 10_000; +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); +const validId = (value: unknown): value is string => + typeof value === 'string' && /^\d{1,20}$/.test(value); +const pageNumber = (value: unknown): number | undefined => + (typeof value === 'number' || + (typeof value === 'string' && /^\d+$/.test(value))) && + Number.isSafeInteger(Number(value)) && + Number(value) >= 0 + ? Number(value) + : undefined; + +class CollectionLimitError extends Error {} +export class PlexCollectionAuthorityError extends Error {} + +const metadataPage = ( + value: unknown, + offset: number, + maximum: number +): { items: unknown[]; complete: boolean } => { + const container = + isRecord(value) && isRecord(value.MediaContainer) + ? value.MediaContainer + : undefined; + if (!container) throw new Error('Invalid Plex collection page.'); + const items = Array.isArray(container.Metadata) + ? container.Metadata + : container.Metadata === undefined && pageNumber(container.size) === 0 + ? [] + : undefined; + if (!items || items.length > PAGE_SIZE) + throw new Error('Invalid Plex collection page.'); + if ( + container.size !== undefined && + pageNumber(container.size) !== items.length + ) + throw new Error('Invalid Plex page size.'); + if (container.offset !== undefined && pageNumber(container.offset) !== offset) + throw new Error('Invalid Plex pagination offset.'); + const total = container.totalSize; + if (offset + items.length > maximum) throw new CollectionLimitError(); + if (total !== undefined) { + if (pageNumber(total) === undefined) + throw new Error('Invalid Plex collection total.'); + if (Number(total) > maximum) throw new CollectionLimitError(); + if ( + offset + items.length > Number(total) || + (!items.length && offset < Number(total)) + ) + throw new Error('Incomplete Plex collection page.'); + return { items, complete: offset + items.length === Number(total) }; + } + return { items, complete: items.length < PAGE_SIZE }; +}; + +interface VerifiedSeries { + id: string; + libraryId: string; + guid: string; +} +interface VerifiedCollection { + id: string; + name: string; + guid: string; +} + +/** Only current-account PMS owner authority is admitted; no owner-token fallback. */ +export class PlexSeriesCollectionsAPI extends PlexAPI { + private machineId?: string; + constructor(private readonly context: MediaServerCollectionContext) { + super({ + plexToken: context.user.plexToken, + plexSettings: getSettings().plex, + }); + } + + public async verifyOwner(): Promise { + const { user } = this.context; + if (!user.plexId || !user.plexToken) return false; + const account = new PlexSavedItemAPI(user.plexToken); + await account.verifyAccount(user.plexId); + const status = await this.getStatus(); + const machineIdentifier = status.MediaContainer.machineIdentifier; + const configuredMachineId = getSettings().plex.machineId; + if ( + !machineIdentifier || + (configuredMachineId && machineIdentifier !== configuredMachineId) + ) + throw new PlexCollectionAuthorityError('Plex server identity changed.'); + const ownedServerIds = await account.getOwnedServerIds(); + const owned = ownedServerIds.includes(machineIdentifier); + if (owned) this.machineId = machineIdentifier; + return owned; + } + + private async series(): Promise { + if (!validId(this.context.itemId)) + throw new Error('Invalid Plex series item.'); + const { data } = await this.request( + 'GET', + `/library/metadata/${this.context.itemId}`, + undefined, + { params: { includeGuids: 1 } } + ); + const container = + isRecord(data) && isRecord(data.MediaContainer) + ? data.MediaContainer + : undefined; + const item = + Array.isArray(container?.Metadata) && container.Metadata.length === 1 + ? container.Metadata[0] + : undefined; + if ( + !isRecord(item) || + String(item.ratingKey) !== this.context.itemId || + item.type !== 'show' || + typeof item.guid !== 'string' || + !item.guid.length || + item.guid.length > 512 || + !Array.isArray(item.Guid) || + item.Guid.length > 100 || + !item.Guid.some( + (guid) => isRecord(guid) && guid.id === `tmdb://${this.context.tmdbId}` + ) || + !validId(String(item.librarySectionID)) + ) + throw new Error('Plex series identity could not be verified.'); + return { + id: this.context.itemId, + libraryId: String(item.librarySectionID), + guid: String(item.guid), + }; + } + + private collection( + value: unknown, + libraryId: string, + { + sectionScoped = false, + allowOmittedSmart = false, + }: { sectionScoped?: boolean; allowOmittedSmart?: boolean } = {} + ): VerifiedCollection | undefined { + const itemLibraryId = + isRecord(value) && sectionScoped && value.librarySectionID === undefined + ? libraryId + : isRecord(value) + ? value.librarySectionID + : undefined; + if ( + !isRecord(value) || + value.type !== 'collection' || + String(itemLibraryId) !== libraryId || + !validId(String(value.ratingKey)) || + typeof value.title !== 'string' || + !value.title.length || + value.title.length > 512 || + typeof value.guid !== 'string' || + !/^collection:\/\/[a-z\d-]{1,128}$/i.test(value.guid) + ) + throw new Error('Invalid Plex collection identity.'); + if ( + value.smart !== false && + value.smart !== 0 && + value.smart !== '0' && + !(allowOmittedSmart && value.smart === undefined) + ) + return undefined; + if (value.subtype !== 'show') return undefined; + return { id: String(value.ratingKey), name: value.title, guid: value.guid }; + } + + private async collections(libraryId: string): Promise { + const collections: VerifiedCollection[] = []; + const seen = new Set(); + for (let offset = 0; offset <= MAX_COLLECTIONS; offset += PAGE_SIZE) { + const { data } = await this.request( + 'GET', + `/library/sections/${libraryId}/collections`, + undefined, + { + headers: { + 'X-Plex-Container-Start': String(offset), + 'X-Plex-Container-Size': String(PAGE_SIZE), + }, + } + ); + const page = metadataPage(data, offset, MAX_COLLECTIONS); + for (const item of page.items) { + const collection = this.collection(item, libraryId, { + sectionScoped: true, + allowOmittedSmart: true, + }); + if (!collection) continue; + if (seen.has(collection.id)) + throw new Error('Repeated Plex collection identity.'); + seen.add(collection.id); + collections.push(collection); + } + if (page.complete) return collections; + } + throw new CollectionLimitError(); + } + + private async member( + collectionId: string, + series: VerifiedSeries + ): Promise { + let found = false; + const seen = new Set(); + for (let offset = 0; offset <= MAX_MEMBERS; offset += PAGE_SIZE) { + const { data } = await this.request( + 'GET', + `/library/collections/${collectionId}/children`, + undefined, + { + headers: { + 'X-Plex-Container-Start': String(offset), + 'X-Plex-Container-Size': String(PAGE_SIZE), + }, + } + ); + const page = metadataPage(data, offset, MAX_MEMBERS); + for (const item of page.items) { + if ( + !isRecord(item) || + item.type !== 'show' || + !validId(String(item.ratingKey)) + ) + throw new Error('Invalid Plex collection member.'); + const key = String(item.ratingKey); + if (seen.has(key)) throw new Error('Repeated Plex collection member.'); + seen.add(key); + if (key === series.id) { + if (item.guid !== series.guid) + throw new Error('Plex collection member identity changed.'); + found = true; + } + } + if (page.complete) return found; + } + throw new CollectionLimitError(); + } + + public async read(): Promise { + const base: MediaServerCollectionsStatus = { + serverType: MediaServerType.PLEX, + available: false, + collections: [], + }; + if (!this.context.user.plexId || !this.context.user.plexToken) + return { ...base, reason: 'account-not-linked' }; + if (!validId(this.context.itemId)) + return { ...base, reason: 'series-not-found' }; + if (!(await this.verifyOwner())) + return { ...base, reason: 'not-authorized' }; + try { + const series = await this.series(); + const collections = await this.collections(series.libraryId); + const options = await mapWithConcurrency( + collections, + 5, + async (collection) => ({ + id: collection.id, + name: collection.name, + member: await this.member(collection.id, series), + }) + ); + return { ...base, available: true, collections: options }; + } catch (error) { + if (error instanceof CollectionLimitError) + return { ...base, reason: 'collection-limit' }; + throw error; + } + } + + public async setMembership( + collectionId: string, + desired: boolean + ): Promise { + if (!validId(collectionId) || typeof desired !== 'boolean') + throw new Error('Invalid Plex collection selection.'); + if (!(await this.verifyOwner()) || !this.machineId) + throw new PlexCollectionAuthorityError( + 'Plex collection owner authority is required.' + ); + const series = await this.series(); + const collections = await this.collections(series.libraryId); + const selected = collections.find( + (collection) => collection.id === collectionId + ); + if (!selected) + throw new PlexCollectionAuthorityError( + 'Choose an existing eligible collection in this Series library.' + ); + const existing = await this.member(collectionId, series); + if (existing !== desired) { + // Recheck server-side collection identity immediately before mutation. + const { data } = await this.request( + 'GET', + `/library/collections/${collectionId}` + ); + const container = + isRecord(data) && isRecord(data.MediaContainer) + ? data.MediaContainer + : undefined; + const items = Array.isArray(container?.Metadata) + ? container.Metadata + : []; + const current = + items.length === 1 + ? this.collection(items[0], series.libraryId, { + allowOmittedSmart: true, + }) + : undefined; + if ( + !current || + current.id !== selected.id || + current.guid !== selected.guid + ) + throw new PlexCollectionAuthorityError( + 'Plex collection identity changed.' + ); + if (desired) { + await this.request( + 'PUT', + `/library/collections/${collectionId}/items`, + null, + { + params: { + uri: `server://${encodeURIComponent(this.machineId)}/com.plexapp.plugins.library/library/metadata/${series.id}`, + }, + } + ); + } else { + // The collection endpoint is the canonical PMS removal contract. Some + // servers do not honor the documented /library/metadata alias here. + await this.request( + 'DELETE', + `/library/collections/${collectionId}/items/${series.id}` + ); + } + if ((await this.member(collectionId, series)) !== desired) + throw new Error('Plex collection update was not confirmed.'); + } + return this.read(); + } +} + +export const getPlexSeriesCollections = ( + context: MediaServerCollectionContext +): Promise => + new PlexSeriesCollectionsAPI(context).read(); +export const setPlexSeriesCollectionMembership = ( + context: MediaServerCollectionContext, + collectionId: string, + member: boolean +): Promise => + new PlexSeriesCollectionsAPI(context).setMembership(collectionId, member); diff --git a/server/api/servarr/readarr.test.ts b/server/api/servarr/readarr.test.ts index 478903db5..8eedb7958 100644 --- a/server/api/servarr/readarr.test.ts +++ b/server/api/servarr/readarr.test.ts @@ -676,7 +676,12 @@ describe('ReadarrAPI.addBook', () => { 'post', async () => existingBook({ id: 10 }) ); - const commandPostMock = mock.fn(async () => ({ + const commandPostMock = mock.fn< + ( + endpoint: string, + data?: Record + ) => Promise<{ data: { id: number; name: string; status: string } }> + >(async () => ({ data: { id: 101, name: 'BookSearch', status: 'started' }, })); ( diff --git a/server/api/tvdb/videoMetadata.test.ts b/server/api/tvdb/videoMetadata.test.ts index b88596dd6..2c9623cea 100644 --- a/server/api/tvdb/videoMetadata.test.ts +++ b/server/api/tvdb/videoMetadata.test.ts @@ -1,7 +1,21 @@ import ExternalAPI from '@server/api/externalapi'; +import type { AxiosRequestConfig } from 'axios'; import assert from 'node:assert/strict'; import { afterEach, describe, it, mock } from 'node:test'; import Tvdb from './index'; +import type { TvdbBaseResponse, TvdbVideoMetadataRecord } from './interfaces'; + +type MockableTvdbToken = { + refreshToken: () => Promise; +}; + +type MockableVideoRequest = { + get: ( + endpoint: string, + config?: AxiosRequestConfig, + ttl?: number + ) => Promise, 'data'>>; +}; describe('TheTVDB remote-ID video lookup', () => { afterEach(() => { @@ -11,20 +25,19 @@ describe('TheTVDB remote-ID video lookup', () => { it('uses the v4 remote-ID endpoint and filters to the requested media type', async () => { const tvdb = new Tvdb(); mock.method( - tvdb as unknown as object, + tvdb as unknown as MockableTvdbToken, 'refreshToken', async () => undefined ); const request = mock.method( - ExternalAPI.prototype, + ExternalAPI.prototype as unknown as MockableVideoRequest, 'get', - async () => - ({ - data: [ - { id: 10, name: 'A series', type: 'series' }, - { id: 20, name: 'A movie', type: 'movie' }, - ], - }) as never + async () => ({ + data: [ + { id: 10, name: 'A series', type: 'series' }, + { id: 20, name: 'A movie', type: 'movie' }, + ], + }) ); const result = await tvdb.getVideoMetadataByTmdbId({ @@ -39,20 +52,19 @@ describe('TheTVDB remote-ID video lookup', () => { it('rejects an ambiguous same-type remote-ID match', async () => { const tvdb = new Tvdb(); mock.method( - tvdb as unknown as object, + tvdb as unknown as MockableTvdbToken, 'refreshToken', async () => undefined ); mock.method( - ExternalAPI.prototype, + ExternalAPI.prototype as unknown as MockableVideoRequest, 'get', - async () => - ({ - data: [ - { id: 20, name: 'First movie', type: 'movie' }, - { id: 21, name: 'Second movie', type: 'movie' }, - ], - }) as never + async () => ({ + data: [ + { id: 20, name: 'First movie', type: 'movie' }, + { id: 21, name: 'Second movie', type: 'movie' }, + ], + }) ); const result = await tvdb.getVideoMetadataByTmdbId({ diff --git a/server/entity/UserSettings.ts b/server/entity/UserSettings.ts index 613d0ae4a..00fa7e797 100644 --- a/server/entity/UserSettings.ts +++ b/server/entity/UserSettings.ts @@ -10,6 +10,7 @@ import { Notification, hasNotificationType } from '@server/lib/notifications'; import { NotificationAgentKey } from '@server/lib/settings'; import { DbAwareColumn } from '@server/utils/DbColumnHelper'; import type { AdvancedThemeOverrides } from '@server/utils/advancedThemeOverrides'; +import type { DetailDisclosureOrder } from '@server/utils/detailDisclosureOrder'; import { Column, Entity, @@ -225,6 +226,9 @@ export class UserSettings { @Column({ type: 'simple-json', nullable: true }) public detailDisclosurePins?: UserSettingsDetailDisclosuresByMedia; + @Column({ type: 'simple-json', nullable: true }) + public detailDisclosureOrder?: DetailDisclosureOrder; + @Column({ type: 'simple-json', nullable: true }) public mediaFilterPins?: UserMediaFilterPins; @@ -283,6 +287,7 @@ export class UserSettings { detailDisclosureArtistsPinned: this.detailDisclosureArtistsPinned, detailDisclosureSubjectTagsPinned: this.detailDisclosureSubjectTagsPinned, detailDisclosurePins: this.detailDisclosurePins, + detailDisclosureOrder: this.detailDisclosureOrder, mediaFilterPins: this.mediaFilterPins, advancedThemeOverrides: this.advancedThemeOverrides, }; diff --git a/server/interfaces/api/userSettingsInterfaces.ts b/server/interfaces/api/userSettingsInterfaces.ts index c2a3dd0f2..a35a701f5 100644 --- a/server/interfaces/api/userSettingsInterfaces.ts +++ b/server/interfaces/api/userSettingsInterfaces.ts @@ -36,6 +36,9 @@ export const mediaFilterValues = [ 'magazine', 'author', 'software', + 'retro', + 'modern', + 'game', ] as const; export type MediaFilterValue = (typeof mediaFilterValues)[number]; export type UserMediaFilterPins = Partial< @@ -52,13 +55,16 @@ export interface UserSettingsCardTextResponse { export type UserRequestRootFolders = Record; export type DetailDisclosurePin = + | 'overview' | 'cast' | 'crew' | 'artists' | 'subjectTags' | 'collection' + | 'mediaServer' | 'details' | 'advancedOptions' + | 'taskFilters' | 'filters' | 'mediaFilters' | 'sortBy'; @@ -66,12 +72,15 @@ export type DetailDisclosurePin = export type DetailDisclosureMediaType = 'movie' | 'tv' | 'music' | 'book'; export interface UserSettingsDetailDisclosureResponse { + overview?: boolean; details?: boolean; advancedOptions?: boolean; + taskFilters?: boolean; filters?: boolean; mediaFilters?: boolean; sortBy?: boolean; collection?: boolean; + mediaServer?: boolean; cast?: boolean; crew?: boolean; artists?: boolean; diff --git a/server/lib/availabilitySync.test.ts b/server/lib/availabilitySync.test.ts index 557e40e67..b95555781 100644 --- a/server/lib/availabilitySync.test.ts +++ b/server/lib/availabilitySync.test.ts @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { beforeEach, describe, it } from 'node:test'; +import { afterEach, beforeEach, describe, it, mock } from 'node:test'; import type { JellyfinLibraryItem, @@ -11,6 +11,7 @@ import PlexAPI from '@server/api/plexapi'; import RadarrAPI from '@server/api/servarr/radarr'; import type { SonarrSeason, SonarrSeries } from '@server/api/servarr/sonarr'; import SonarrAPI from '@server/api/servarr/sonarr'; +import type { TmdbTvScanDetails } from '@server/api/themoviedb/interfaces'; import { MediaStatus, MediaType } from '@server/constants/media'; import { MediaServerType } from '@server/constants/server'; import { getRepository } from '@server/datasource'; @@ -143,6 +144,47 @@ import availabilitySync from '@server/lib/availabilitySync'; setupTestDb(); +// AvailabilitySync also enriches season counts through TMDB. Stub the actual +// singleton client's scan method: its arrow function shadows prototype mocks. +// Keep the external-network guard enabled and the real scan/reconciler intact. +const scanSeasonCounts = new Map([ + [1408, 8], + [1409, 8], + [1410, 8], + [1411, 1], + [2000, 8], + [2001, 1], + [2002, 4], + [2003, 1], +]); +let unexpectedScanIds: number[] = []; +let scanIds: number[] = []; +let unexpectedTvdbIds: number[] = []; + +function fakeTmdbShow(tvId: number, seasonCount: number): TmdbTvScanDetails { + return { + id: tvId, + name: 'Test Show', + original_name: 'Test Show', + first_air_date: '2001-01-01', + external_ids: {}, + keywords: { results: [] }, + genres: [], + episode_run_time: [45], + created_by: [], + production_companies: [], + networks: [], + seasons: Array.from({ length: seasonCount }, (_, index) => ({ + id: index + 1, + season_number: index + 1, + episode_count: 22, + name: `Season ${index + 1}`, + overview: '', + air_date: '2001-01-01', + })), + }; +} + function configureSonarr(overrides: Partial[] = [{}]): void { const settings = getSettings(); settings.sonarr = overrides.map((o, i) => ({ @@ -318,6 +360,36 @@ function fakeSonarrSeasons( describe('AvailabilitySync', () => { beforeEach(async () => { + unexpectedScanIds = []; + scanIds = []; + unexpectedTvdbIds = []; + mock.method( + availabilitySync.tmdb, + 'getTvShowForScan', + async ({ + tvId, + }: Parameters[0]) => { + scanIds.push(tvId); + const seasonCount = scanSeasonCounts.get(tvId); + if (seasonCount === undefined) { + unexpectedScanIds.push(tvId); + throw new Error(`Unexpected TMDB scan fixture: ${tvId}`); + } + return fakeTmdbShow(tvId, seasonCount); + } + ); + mock.method( + availabilitySync.tmdb, + 'getShowByTvdbIdForScan', + async ({ + tvdbId, + }: Parameters< + typeof availabilitySync.tmdb.getShowByTvdbIdForScan + >[0]) => { + unexpectedTvdbIds.push(tvdbId); + throw new Error(`Unexpected TVDB scan fixture: ${tvdbId}`); + } + ); deletionMovieIds = []; getDeletionItemImpl = async () => undefined; getSystemInfoImpl = async () => ({ ServerName: 'Test' }); @@ -345,6 +417,17 @@ describe('AvailabilitySync', () => { await userRepository.save(admin); }); + afterEach(() => { + try { + // The scan catches metadata failures. Assert outside that catch so an + // accidental new lookup cannot silently pass with an unrelated fixture. + assert.deepEqual(unexpectedScanIds, []); + assert.deepEqual(unexpectedTvdbIds, []); + } finally { + mock.restoreAll(); + } + }); + describe('TV season availability - Jellyfin', () => { it('preserves season status when only some seasons exist in Jellyfin and Sonarr', async () => { configureJellyfin(); @@ -417,6 +500,8 @@ describe('AvailabilitySync', () => { await availabilitySync.run(); + assert.deepEqual(scanIds, [1408]); + const updated = await mediaRepository.findOneOrFail({ where: { tmdbId: 1408 }, relations: { seasons: true }, @@ -495,6 +580,7 @@ describe('AvailabilitySync', () => { where: { tmdbId: 1409 }, relations: { seasons: true }, }); + assert.deepEqual(scanIds, [1409]); const s6 = updated.seasons.find((s) => s.seasonNumber === 6); assert.strictEqual( @@ -592,6 +678,7 @@ describe('AvailabilitySync', () => { where: { tmdbId: 1410 }, relations: { seasons: true }, }); + assert.deepEqual(scanIds, [1410]); const s6 = updated.seasons.find((s) => s.seasonNumber === 6); assert.strictEqual( @@ -677,6 +764,7 @@ describe('AvailabilitySync', () => { where: { tmdbId: 1411 }, relations: { seasons: true }, }); + assert.deepEqual(scanIds, [1411]); assert.strictEqual( updated.seasons[0].status, @@ -745,6 +833,7 @@ describe('AvailabilitySync', () => { assert.strictEqual(updated.status, MediaStatus.AVAILABLE); assert.strictEqual(updated.seasons[0].status, MediaStatus.AVAILABLE); + assert.deepEqual(scanIds, [2003]); }); it('preserves season status when Plex returns empty season metadata entries', async () => { @@ -823,6 +912,7 @@ describe('AvailabilitySync', () => { where: { tmdbId: 2000 }, relations: { seasons: true }, }); + assert.deepEqual(scanIds, [2000]); const s6 = updated.seasons.find((s) => s.seasonNumber === 6); assert.strictEqual( @@ -904,6 +994,7 @@ describe('AvailabilitySync', () => { where: { tmdbId: 2001 }, relations: { seasons: true }, }); + assert.deepEqual(scanIds, [2001]); assert.strictEqual( updated.seasons[0].status, @@ -973,6 +1064,7 @@ describe('AvailabilitySync', () => { where: { tmdbId: 2002 }, relations: { seasons: true }, }); + assert.deepEqual(scanIds, [2002]); const s2 = updated.seasons.find((s) => s.seasonNumber === 2); const s4 = updated.seasons.find((s) => s.seasonNumber === 4); @@ -996,6 +1088,50 @@ describe('AvailabilitySync', () => { }); describe('scan lifecycle and pagination', () => { + it('preserves available seasons when TMDB enrichment is unavailable', async () => { + configurePlex(); + configureSonarr([]); + const mediaRepository = getRepository(Media); + const media = await mediaRepository.save( + new Media({ + tmdbId: 2003, + mediaType: MediaType.TV, + status: MediaStatus.AVAILABLE, + ratingKey: 'metadata-unavailable-show', + seasons: [ + new Season({ + seasonNumber: 1, + status: MediaStatus.AVAILABLE, + status4k: MediaStatus.UNKNOWN, + }), + ], + }) + ); + getMetadataImpl = async () => fakePlexShow('metadata-unavailable-show'); + getChildrenMetadataImpl = async (key) => + key === 'metadata-unavailable-show' + ? [fakePlexSeason(1, 'metadata-unavailable-season')] + : fakePlexEpisodes(22); + const lookup = mock.method( + availabilitySync.tmdb, + 'getTvShowForScan', + async () => { + throw new Error('TMDB fixture unavailable'); + } + ); + + await availabilitySync.run(); + + assert.equal(lookup.mock.callCount(), 1); + assert.deepEqual(lookup.mock.calls[0].arguments, [{ tvId: 2003 }]); + const updated = await mediaRepository.findOneOrFail({ + where: { id: media.id }, + relations: { seasons: true }, + }); + assert.equal(updated.status, MediaStatus.AVAILABLE); + assert.equal(updated.seasons[0].status, MediaStatus.AVAILABLE); + }); + it('cleans up a movie after both Arr and the media server confirm deletion', async () => { configureJellyfin(); configureSonarr([]); diff --git a/server/lib/requestStatus.ts b/server/lib/requestStatus.ts index b41c96484..9c237595c 100644 --- a/server/lib/requestStatus.ts +++ b/server/lib/requestStatus.ts @@ -1273,6 +1273,50 @@ export const recordRequestStatusOverride = async ( await persistStatusEvent(request, status, latestEvent ?? undefined); }; +export const recordRequestRetry = async (requestId: number): Promise => { + const request = await loadRequest(requestId); + if (!request) { + return; + } + + const repository = getStatusEventRepository(); + const latestEvent = await getLatestStatusEvent(requestId); + const attempt = latestEvent?.attempt ?? 0; + const fingerprint = `retry:${latestEvent?.id ?? 0}:${attempt}`.slice(0, 255); + + try { + await repository.insert( + new MediaRequestStatusEvent({ + requestId: request.id, + requestedById: request.requestedBy.id, + mediaId: request.media.id, + mediaType: request.type, + stage: RequestStatusStage.APPROVED, + attempt, + format: request.bookFormat ?? null, + service: getServiceName(request) ?? latestEvent?.service ?? null, + message: 'The request was retried and is waiting to be dispatched.', + percent: null, + size: null, + sizeLeft: null, + estimatedCompletionTime: null, + downloadCount: 0, + downloadId: null, + fingerprint, + }) + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (!message.toLocaleLowerCase().includes('unique')) { + logger.warn('Unable to persist request retry event', { + label: 'Request Status', + requestId, + errorMessage: message, + }); + } + } +}; + export const recordRequestCancellation = async ( request: Pick< RequestLike, diff --git a/server/lib/scanners/plex/index.test.ts b/server/lib/scanners/plex/index.test.ts index 725ca37e5..76a1ba30d 100644 --- a/server/lib/scanners/plex/index.test.ts +++ b/server/lib/scanners/plex/index.test.ts @@ -1,6 +1,9 @@ import MusicBrainz from '@server/api/musicbrainz'; import OpenLibraryAPI from '@server/api/openlibrary'; -import PlexAPI, { type PlexLibraryItem } from '@server/api/plexapi'; +import PlexAPI, { + type PlexLibraryItem, + type PlexMetadata, +} from '@server/api/plexapi'; import { MediaStatus, MediaType } from '@server/constants/media'; import { MediaServerType } from '@server/constants/server'; import { getRepository } from '@server/datasource'; @@ -248,9 +251,19 @@ describe('Plex music and audiobook library scanning', () => { 'getReleaseGroupDetails', async () => ({}) as never ); + // Missing album codecs trigger a second Plex lookup. Mock that actual API + // boundary too; a swallowed network error must not supply this fixture. + const children = mock.method( + PlexAPI.prototype, + 'getChildrenMetadata', + async () => [] + ); await new PlexScanner().run(); + assert.strictEqual(children.mock.callCount(), 1); + assert.deepStrictEqual(children.mock.calls[0].arguments, ['60487']); + const media = await getRepository(Media).findOne({ where: { mbId: 'cf988074-7ee4-4eb3-8a39-42b1467b2de7', @@ -263,6 +276,90 @@ describe('Plex music and audiobook library scanning', () => { assert.strictEqual(media?.status, MediaStatus.AVAILABLE); }); + it('classifies an album from child tracks when album codecs are absent', async () => { + const settings = getSettings(); + settings.main = { ...settings.main, mediaServerType: MediaServerType.PLEX }; + settings.radarr = []; + settings.sonarr = []; + settings.plex = { + ...settings.plex, + ip: 'plex.local', + port: 32400, + useSsl: false, + libraries: [{ id: 'music', name: 'Music', enabled: true, type: 'music' }], + }; + mock.method(PlexAPI.prototype, 'getLibraries', async () => []); + mock.method(PlexAPI.prototype, 'getLibraryContents', async () => ({ + totalSize: 1, + items: [ + { + ratingKey: '60488', + title: 'Child Track Album', + guid: 'mbid://cf988074-7ee4-4eb3-8a39-42b1467b2de7', + addedAt: 1789059800, + updatedAt: 1789059802, + type: 'album', + Media: [], + } satisfies PlexLibraryItem, + ], + })); + mock.method( + MusicBrainz.prototype, + 'getReleaseGroupDetails', + async () => ({}) as never + ); + const track: PlexMetadata = { + ratingKey: '60489', + parentRatingKey: '60488', + guid: 'local://60489', + type: 'track', + title: 'Lossless Track', + Guid: [], + index: 1, + leafCount: 0, + viewedLeafCount: 0, + viewCount: 0, + addedAt: 1789059800, + updatedAt: 1789059802, + Media: [ + { + id: 1, + duration: 1000, + bitrate: 1000, + width: 0, + height: 0, + aspectRatio: 0, + audioChannels: 2, + audioCodec: 'flac', + videoCodec: '', + videoResolution: '', + container: 'flac', + videoFrameRate: '', + videoProfile: '', + }, + ], + }; + const children = mock.method( + PlexAPI.prototype, + 'getChildrenMetadata', + async () => [track] + ); + + await new PlexScanner().run(); + + assert.strictEqual(children.mock.callCount(), 1); + assert.deepStrictEqual(children.mock.calls[0].arguments, ['60488']); + const media = await getRepository(Media).findOneOrFail({ + where: { + mbId: 'cf988074-7ee4-4eb3-8a39-42b1467b2de7', + mediaType: MediaType.MUSIC, + }, + }); + assert.strictEqual(media.status, MediaStatus.AVAILABLE); + assert.strictEqual(media.ratingKeyFlac, '60488'); + assert.ok(!media.ratingKeyMp3); + }); + it('retains separate MP3 and FLAC Plex album identifiers for the same release group', async () => { const settings = getSettings(); settings.main = { ...settings.main, mediaServerType: MediaServerType.PLEX }; diff --git a/server/lib/userSecurityMutation.credentials.test.ts b/server/lib/userSecurityMutation.credentials.test.ts new file mode 100644 index 000000000..461055ea4 --- /dev/null +++ b/server/lib/userSecurityMutation.credentials.test.ts @@ -0,0 +1,192 @@ +import assert from 'node:assert/strict'; +import { after, beforeEach, it } from 'node:test'; + +import dataSource, { getRepository } from '@server/datasource'; +import { User } from '@server/entity/User'; +import { Permission } from '@server/lib/permissions'; +import { setupTestDb } from '@server/test/db'; +import { + runUserSecurityMutationWithActor, + runUserSecurityReadWithActor, + UserMutationActorUnauthorizedError, +} from './userSecurityMutation'; + +// Real application entity/TypeORM selection, but only its disposable in-memory +// test database. No native provider, authentication endpoint or live config. +assert.equal(process.env.NODE_ENV, 'test'); +assert.equal(dataSource.options.type, 'better-sqlite3'); +assert.equal(dataSource.options.database, ':memory:'); +setupTestDb(); +const nativeOptions = { includeMediaServerCredentials: true } as const; +const currentPair = { + plexId: 222, + plexToken: 'fixture-own-plex-token', + jellyfinUserId: '0123456789abcdef0123456789abcdef', + jellyfinAuthToken: 'fixture-own-native-token', + jellyfinDeviceId: 'fixture-own-device', +}; +beforeEach(async () => { + await getRepository(User).update(1, { + plexToken: 'fixture-owner-token-must-not-be-borrowed', + }); + await getRepository(User).update(2, { + ...currentPair, + permissions: 0, + passwordChangedAt: new Date('2026-10-01T00:00:00.000Z'), + }); +}); +after(async () => { + if (dataSource.isInitialized) await dataSource.destroy(); +}); + +const ownActor = () => + runUserSecurityReadWithActor(2, 2, [], async (actor) => actor, nativeOptions); + +it('ordinary repository and authority reads keep native credentials select:false', async () => { + const repository = getRepository(User); + for (const field of ['plexToken', 'jellyfinAuthToken', 'jellyfinDeviceId']) { + assert.equal( + repository.metadata.findColumnWithPropertyName(field)?.isSelect, + false + ); + } + const actors = [ + await repository.findOneByOrFail({ id: 2 }), + await runUserSecurityReadWithActor(2, 2, [], async (actor) => actor), + await runUserSecurityMutationWithActor(2, 2, [], async (actor) => actor), + ]; + for (const actor of actors) { + assert.equal(actor.plexId, currentPair.plexId); + assert.equal(actor.plexToken, undefined); + assert.equal(actor.jellyfinAuthToken, undefined); + assert.equal(actor.jellyfinDeviceId, undefined); + assert.equal(actor.password, undefined); + assert.equal( + actor.passwordChangedAt?.getTime(), + Date.parse('2026-10-01T00:00:00.000Z') + ); + } +}); + +it('explicit native admission loads only fresh own-account credentials for read and mutation', async () => { + for (const admit of [ + runUserSecurityReadWithActor, + runUserSecurityMutationWithActor, + ]) { + const actor = await admit(2, 2, [], async (user) => user, nativeOptions); + assert.equal(actor.id, 2); + assert.equal(actor.permissions, 0); + for (const [field, value] of Object.entries(currentPair)) { + assert.equal(actor[field as keyof User], value); + } + assert.equal(actor.password, undefined); + assert.equal(actor.resetPasswordGuid, undefined); + assert.equal(actor.failedLoginAttempts, undefined); + assert.equal( + actor.passwordChangedAt?.getTime(), + Date.parse('2026-10-01T00:00:00.000Z') + ); + } +}); + +it('credential-bearing actors retain the unchanged filtered serialization boundary', async () => { + const actor = await ownActor(); + for (const payload of [ + actor.filter(), + actor.filter(true), + JSON.parse(JSON.stringify(actor)), + ]) { + for (const field of [ + 'plexToken', + 'jellyfinAuthToken', + 'jellyfinDeviceId', + 'password', + 'passwordChangedAt', + ]) { + assert.equal(Object.hasOwn(payload, field), false); + } + const serialized = JSON.stringify(payload); + assert.equal(serialized.includes(currentPair.plexToken), false); + assert.equal(serialized.includes(currentPair.jellyfinAuthToken), false); + assert.equal(serialized.includes('fixture-owner-token'), false); + } +}); + +it('native credential selection preserves current version and cross-user admission checks', async () => { + for (const admit of [ + runUserSecurityReadWithActor, + runUserSecurityMutationWithActor, + ]) { + let admitted = false; + await assert.rejects( + admit( + 2, + 2, + [], + async () => { + admitted = true; + }, + { + ...nativeOptions, + expectedCredentialVersion: Date.parse('2026-09-30T00:00:00.000Z'), + } + ), + UserMutationActorUnauthorizedError + ); + await assert.rejects( + admit( + 2, + 1, + Permission.ADMIN, + async () => { + admitted = true; + }, + nativeOptions + ), + UserMutationActorUnauthorizedError + ); + await assert.rejects( + admit( + 999, + 999, + [], + async () => { + admitted = true; + }, + nativeOptions + ), + UserMutationActorUnauthorizedError + ); + assert.equal(admitted, false); + } +}); + +it('fresh native revalidation observes persisted rotation/unlink, never another account', async () => { + const repository = getRepository(User); + for (const field of [ + 'plexToken', + 'jellyfinAuthToken', + 'plexId', + 'jellyfinUserId', + ] as const) { + await repository.update(2, currentPair); + const snapshot = await ownActor(); + const changed = + field === 'plexId' + ? 333 + : field === 'jellyfinUserId' + ? '1123456789abcdef0123456789abcdef' + : `fixture-rotated-${field}`; + await repository.update(2, { [field]: changed }); + const current = await ownActor(); + assert.equal(current[field], changed); + assert.notEqual(current[field], snapshot[field]); + await repository.update(2, { [field]: null }); + const unlinked = await ownActor(); + assert.equal(unlinked[field], null); + assert.notEqual( + unlinked.plexToken, + 'fixture-owner-token-must-not-be-borrowed' + ); + } +}); diff --git a/server/lib/userSecurityMutation.ts b/server/lib/userSecurityMutation.ts index e2eb2f145..e14e2472a 100644 --- a/server/lib/userSecurityMutation.ts +++ b/server/lib/userSecurityMutation.ts @@ -29,6 +29,32 @@ export interface UserCredentialVersionOptions { expectedCredentialVersion?: number; } +export interface UserSecurityActorOptions extends UserCredentialVersionOptions { + /** Server-only opt-in for operations using the fresh actor's native account. */ + includeMediaServerCredentials?: boolean; +} + +const loadSecurityActor = ( + actorId: number, + options: UserSecurityActorOptions +): Promise => { + const repository = getRepository(User); + if (options.includeMediaServerCredentials !== true) + return repository.findOneBy({ id: actorId }); + + // Same explicit, own-user credential selection as playback. Keep all default + // identity/permission/version columns and never load password/reset secrets. + return repository + .createQueryBuilder('user') + .addSelect([ + 'user.plexToken', + 'user.jellyfinAuthToken', + 'user.jellyfinDeviceId', + ]) + .where('user.id = :actorId', { actorId }) + .getOne(); +}; + export const runWithUserCredentialVersionContext = ( actorId: number, expectedCredentialVersion: number, @@ -180,7 +206,7 @@ export const runAuthorizedUserSecurityMutation = ( } ); -export interface AuthorizedUserSecurityReadOptions extends UserCredentialVersionOptions { +export interface AuthorizedUserSecurityReadOptions extends UserSecurityActorOptions { permissionCheckOptions?: PermissionCheckOptions; requirePermission?: boolean; } @@ -207,7 +233,7 @@ export const runUserSecurityReadWithActor = async ( throw new Error('A valid user ID is required for a security read.'); } - const actor = await getRepository(User).findOneBy({ id: actorId }); + const actor = await loadSecurityActor(actorId, options); if ( !actor || !isRequestCredentialAuthorityCurrent( @@ -283,11 +309,11 @@ export const runUserSecurityMutationWithActor = ( targetIds: number | number[], permissionForOtherUsers: Permission | Permission[], callback: (actor: User) => Promise, - options: UserCredentialVersionOptions = {} + options: UserSecurityActorOptions = {} ): Promise => { const targets = Array.isArray(targetIds) ? targetIds : [targetIds]; return runUserSecurityMutationUnchecked([actorId, ...targets], async () => { - const actor = await getRepository(User).findOneBy({ id: actorId }); + const actor = await loadSecurityActor(actorId, options); if ( !actor || !isRequestCredentialAuthorityCurrent( diff --git a/server/middleware/apiResponseCache.test.ts b/server/middleware/apiResponseCache.test.ts index 8c6485d70..c3d374839 100644 --- a/server/middleware/apiResponseCache.test.ts +++ b/server/middleware/apiResponseCache.test.ts @@ -17,6 +17,12 @@ const createApp = (authenticated = true) => { app.use(apiResponseCache); app.get('/discover/books', (_req, res) => res.json({ results: [] })); app.get('/book/OL1W', (_req, res) => res.json({ id: 'OL1W' })); + app.get('/tv/42/media-server-collections', (_req, res) => + res.json({ available: true, collections: [] }) + ); + app.get('/tv/42/media-server-saved-item', (_req, res) => + res.json({ available: true, saved: false }) + ); app.get('/settings/public', (_req, res) => res.json({ initialized: true })); app.get('/settings/discover', (_req, res) => res.json([])); app.get('/request/count', (_req, res) => res.json({ pending: 0 })); @@ -58,6 +64,18 @@ describe('apiResponseCache', () => { assert.match(res.headers['cache-control'], /max-age=300/); }); + it('never caches mutable media-server collection or saved-item state', async () => { + for (const path of [ + '/tv/42/media-server-collections?is4k=false', + '/tv/42/media-server-saved-item?is4k=false', + ]) { + const res = await request(createApp()).get(path); + + assert.equal(res.status, 200); + assert.equal(res.headers['cache-control'], 'private, no-store'); + } + }); + it('never stores responses authenticated by API key headers', async () => { const res = await request(createApp()) .get('/book/OL1W') diff --git a/server/middleware/apiResponseCache.ts b/server/middleware/apiResponseCache.ts index 917e1e734..dff34e300 100644 --- a/server/middleware/apiResponseCache.ts +++ b/server/middleware/apiResponseCache.ts @@ -18,6 +18,15 @@ const cacheableRoutePatterns = [ ]; const getCacheControl = (path: string, isAuthenticated: boolean) => { + if ( + /^\/tv\/\d+\/media-server-(?:collections|saved-item)(?:\/|$)/.test(path) + ) { + // Membership and saved state are mutable outside SeerrNG and are re-read + // immediately before every add/remove action. A browser-cached response is + // not authoritative. + return 'private, no-store'; + } + if (path === '/settings/public') { // These values control authentication and media visibility. A stale 304 // can silently revert the client to old feature flags, so never reuse it. diff --git a/server/migration/postgres/1791000000000-AddDetailDisclosureOrder.ts b/server/migration/postgres/1791000000000-AddDetailDisclosureOrder.ts new file mode 100644 index 000000000..c2df25c64 --- /dev/null +++ b/server/migration/postgres/1791000000000-AddDetailDisclosureOrder.ts @@ -0,0 +1,14 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; +export class AddDetailDisclosureOrder1791000000000 implements MigrationInterface { + name = 'AddDetailDisclosureOrder1791000000000'; + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'ALTER TABLE "user_settings" ADD "detailDisclosureOrder" text' + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'ALTER TABLE "user_settings" DROP COLUMN "detailDisclosureOrder"' + ); + } +} diff --git a/server/migration/sqlite/1791000000000-AddDetailDisclosureOrder.ts b/server/migration/sqlite/1791000000000-AddDetailDisclosureOrder.ts new file mode 100644 index 000000000..7f5d16aaa --- /dev/null +++ b/server/migration/sqlite/1791000000000-AddDetailDisclosureOrder.ts @@ -0,0 +1,14 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; +export class AddDetailDisclosureOrder1791000000000 implements MigrationInterface { + name = 'AddDetailDisclosureOrder1791000000000'; + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'ALTER TABLE "user_settings" ADD COLUMN "detailDisclosureOrder" text' + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'ALTER TABLE "user_settings" DROP COLUMN "detailDisclosureOrder"' + ); + } +} diff --git a/server/models/MediaServerCollections.ts b/server/models/MediaServerCollections.ts new file mode 100644 index 000000000..b2d44754f --- /dev/null +++ b/server/models/MediaServerCollections.ts @@ -0,0 +1,36 @@ +import type { MediaServerType } from '@server/constants/server'; +import type { User } from '@server/entity/User'; + +export interface MediaServerCollectionOption { + id: string; + name: string; + member: boolean; +} + +export interface MediaServerCollectionsStatus { + serverType: MediaServerType; + available: boolean; + reason?: + | 'account-not-linked' + | 'not-authorized' + | 'series-not-found' + | 'unsupported-server' + | 'collection-limit'; + collections: MediaServerCollectionOption[]; +} + +/** Trusted server-side context only; never accept account tokens or item IDs from the client. */ +export interface MediaServerCollectionContext { + user: Pick< + User, + | 'id' + | 'plexId' + | 'plexToken' + | 'jellyfinUserId' + | 'jellyfinAuthToken' + | 'jellyfinDeviceId' + >; + tmdbId: number; + itemId: string; + is4k: boolean; +} diff --git a/server/models/MediaServerSavedItem.ts b/server/models/MediaServerSavedItem.ts new file mode 100644 index 000000000..1c324e59a --- /dev/null +++ b/server/models/MediaServerSavedItem.ts @@ -0,0 +1,9 @@ +import type { MediaServerType } from '@server/constants/server'; + +export interface MediaServerSavedItemStatus { + serverType: MediaServerType; + kind: 'watchlist' | 'favorites'; + available: boolean; + saved?: boolean; + reason?: 'account-not-linked' | 'series-not-found'; +} diff --git a/server/routes/artist.test.ts b/server/routes/artist.test.ts index 7c8e6447f..cb5fdfc57 100644 --- a/server/routes/artist.test.ts +++ b/server/routes/artist.test.ts @@ -120,6 +120,11 @@ describe('GET /artist/:id/similar', () => { }); it('returns paginated similar artists from the association graph', async () => { + const getMapping = mock.method( + TmdbPersonMapper.prototype, + 'getMapping', + async () => ({ personId: null, profilePath: null }) + ); mock.method(ListenBrainzAPI.prototype, 'getArtist', async () => artistDetails(3) ); @@ -132,6 +137,10 @@ describe('GET /artist/:id/similar', () => { ); assert.strictEqual(res.status, 200); + assert.deepStrictEqual( + getMapping.mock.calls.map((call) => call.arguments), + [['root-artist', 'Root Artist']] + ); assert.strictEqual(res.body.page, 2); assert.strictEqual(res.body.pageSize, 1); assert.strictEqual(res.body.totalPages, 3); @@ -146,6 +155,11 @@ describe('GET /artist/:id/similar', () => { }); it('normalizes invalid pagination input and caps page size', async () => { + const getMapping = mock.method( + TmdbPersonMapper.prototype, + 'getMapping', + async () => ({ personId: null, profilePath: null }) + ); mock.method(ListenBrainzAPI.prototype, 'getArtist', async () => artistDetails(60) ); @@ -158,6 +172,10 @@ describe('GET /artist/:id/similar', () => { ); assert.strictEqual(res.status, 200); + assert.deepStrictEqual( + getMapping.mock.calls.map((call) => call.arguments), + [['root-artist', 'Root Artist']] + ); assert.strictEqual(res.body.page, 500); assert.strictEqual(res.body.pageSize, 50); assert.strictEqual(res.body.totalPages, 2); diff --git a/server/routes/association.test.ts b/server/routes/association.test.ts index 0ab9feca0..4a07999c8 100644 --- a/server/routes/association.test.ts +++ b/server/routes/association.test.ts @@ -3,6 +3,7 @@ import { afterEach, before, describe, it, mock } from 'node:test'; import ExternalAPI from '@server/api/externalapi'; import ListenBrainzAPI from '@server/api/listenbrainz'; +import TheAudioDb from '@server/api/theaudiodb'; import TmdbPersonMapper from '@server/api/themoviedb/personMapper'; import { MediaType } from '@server/constants/media'; import { getRepository } from '@server/datasource'; @@ -337,6 +338,48 @@ const albumDetails = { type: 'Album', }; +function mockSimilarArtistEnrichment(count: number, requestCount = 1) { + const ids = Array.from( + { length: count }, + (_, index) => `similar-${index + 1}` + ); + const people = ids + .filter((_, index) => index % 2 === 0) + .map((artistId) => ({ + artistId, + artistName: `Similar Artist ${Number(artistId.split('-')[1])}`, + })); + const images = mock.method( + TheAudioDb.prototype, + 'batchGetArtistImages', + async () => + Object.fromEntries( + ids.map((id) => [ + id, + { + artistThumb: `https://www.theaudiodb.com/${id}.jpg`, + artistBackground: null, + }, + ]) + ) + ); + const mappings = mock.method( + TmdbPersonMapper.prototype, + 'batchGetMappings', + async () => [] + ); + return () => { + assert.deepStrictEqual( + images.mock.calls.map((call) => call.arguments), + Array.from({ length: requestCount }, () => [ids]) + ); + assert.deepStrictEqual( + mappings.mock.calls.map((call) => call.arguments), + Array.from({ length: requestCount }, () => [people]) + ); + }; +} + function mockOpenLibraryBook() { mockPrivate(ExternalAPI.prototype, 'get', async (endpoint: unknown) => { if (endpoint === '/works/OLROOTW.json') { @@ -524,6 +567,7 @@ describe('GET /association/:mediaType/:id', () => { }); it('returns similar artist edges for an artist and respects weak filtering', async () => { + const images = mockSimilarArtistEnrichment(12, 2); mock.method(ListenBrainzAPI.prototype, 'getArtist', async (mbid: string) => artistDetails(mbid, 'Root Artist', 12) ); @@ -539,6 +583,7 @@ describe('GET /association/:mediaType/:id', () => { ); assert.strictEqual(defaultRes.status, 200); + images(); assert.strictEqual(defaultRes.body.root.title, 'Root Artist'); assert.strictEqual( defaultRes.body.edges.some( @@ -554,6 +599,7 @@ describe('GET /association/:mediaType/:id', () => { }); it('builds album associations from the root album artist', async () => { + const images = mockSimilarArtistEnrichment(2); mock.method( ListenBrainzAPI.prototype, 'getAlbum', @@ -571,6 +617,7 @@ describe('GET /association/:mediaType/:id', () => { const res = await agent.get('/association/album/album-root'); assert.strictEqual(res.status, 200); + images(); assert.strictEqual(res.body.root.title, 'Root Album'); assert.ok( res.body.edges.some( diff --git a/server/routes/auth.test.ts b/server/routes/auth.test.ts index 823d9988c..3d7133c65 100644 --- a/server/routes/auth.test.ts +++ b/server/routes/auth.test.ts @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import dns from 'node:dns/promises'; import { after, afterEach, @@ -28,6 +29,7 @@ import { checkUser } from '@server/middleware/auth'; import { setupTestDb } from '@server/test/db'; import { ApiError } from '@server/types/error'; import { waitForBackgroundTasks } from '@server/utils/backgroundTasks'; +import { isSafeHttpUrl } from '@server/utils/security'; import axios from 'axios'; import cookieParser from 'cookie-parser'; import type { Express } from 'express'; @@ -2710,6 +2712,35 @@ describe('POST /auth/reset-password/:guid', () => { }); describe('OpenID Connect', () => { + let restoreFixtureDns = () => {}; + + beforeEach(() => { + // Fetch is mocked below; isolate only its explicit public issuer's DNS. + const lookup = mock.method( + dns, + 'lookup', + async (hostname: string, options: unknown) => { + assert.deepStrictEqual(options, { all: true }); + if (hostname !== 'example.com') { + throw new Error(`Unconfigured fixture DNS hostname: ${hostname}`); + } + return [{ address: '93.184.216.34', family: 4 }]; + } + ); + restoreFixtureDns = () => lookup.mock.restore(); + }); + + afterEach(() => restoreFixtureDns()); + + it('isolates public fixture DNS without admitting unknown or private hosts', async () => { + assert.strictEqual(await isSafeHttpUrl('https://example.com/'), true); + assert.strictEqual( + await isSafeHttpUrl('https://unconfigured.example.com/'), + false + ); + assert.strictEqual(await isSafeHttpUrl('https://127.0.0.1/'), false); + }); + it('bounds OIDC provider requests', () => { assert.strictEqual(OIDC_HTTP_TIMEOUT_SECONDS, 10); }); diff --git a/server/routes/book.test.ts b/server/routes/book.test.ts index 3c5f6b2d3..c8474ad30 100644 --- a/server/routes/book.test.ts +++ b/server/routes/book.test.ts @@ -145,6 +145,33 @@ function mockBookDetailsWithoutCover() { })); } +function mockLinkedBookshelfMetadata() { + const book = { + id: 44, + foreignBookId: 'fixture-book-44', + title: 'The Test Book', + authorTitle: 'Test Author', + }; + const getBook = mock.method( + ReadarrAPI.prototype, + 'getBook', + async () => book + ); + const lookup = mock.method(ReadarrAPI.prototype, 'lookupBook', async () => [ + book, + ]); + return () => { + assert.deepStrictEqual( + getBook.mock.calls.map((call) => call.arguments), + [[44, 300]] + ); + assert.deepStrictEqual( + lookup.mock.calls.map((call) => call.arguments), + [['isbn:9780000000002']] + ); + }; +} + describe('GET /book/:id', () => { it('resolves a Bookshelf series member using its title hint and exact provider ID', async () => { const settings = getSettings(); @@ -486,6 +513,7 @@ describe('GET /book/:id', () => { it('does not substitute a linked Bookshelf cover when Open Library has no cover', async () => { mockBookDetailsWithoutCover(); + const verifyLibrary = mockLinkedBookshelfMetadata(); const settings = getSettings(); const priorReadarr = settings.readarr; @@ -537,6 +565,7 @@ describe('GET /book/:id', () => { assert.strictEqual(res.status, 200); assert.strictEqual(res.body.posterPath, undefined); + verifyLibrary(); } finally { settings.readarr = priorReadarr; } @@ -544,6 +573,7 @@ describe('GET /book/:id', () => { it('retains the Open Library cover for an available linked book', async () => { mockBookDetails(); + const verifyLibrary = mockLinkedBookshelfMetadata(); const settings = getSettings(); const priorReadarr = settings.readarr; @@ -598,6 +628,7 @@ describe('GET /book/:id', () => { res.body.posterPath, 'https://covers.openlibrary.org/b/id/123-L.jpg' ); + verifyLibrary(); } finally { settings.readarr = priorReadarr; } diff --git a/server/routes/index.test.ts b/server/routes/index.test.ts index e6b193f95..6608c3172 100644 --- a/server/routes/index.test.ts +++ b/server/routes/index.test.ts @@ -3,6 +3,7 @@ import path from 'node:path'; import { afterEach, before, describe, it, mock } from 'node:test'; import PushoverAPI from '@server/api/pushover'; +import TheMovieDb from '@server/api/themoviedb'; import { DiscoverSliderType } from '@server/constants/discover'; import { MediaRequestStatus, @@ -484,9 +485,49 @@ describe('Top-level API route validation', () => { }); it('allows unauthenticated login backdrop requests', async () => { + const calls: unknown[][] = []; + const get = mock.method( + TheMovieDb.prototype as unknown as { + get: (...args: unknown[]) => Promise; + }, + 'get', + async (...args: unknown[]) => { + calls.push(args); + assert.strictEqual(args[0], '/trending/all/week'); + return { + page: 1, + total_pages: 1, + total_results: 1, + results: [ + { + id: 100, + media_type: 'movie', + title: 'Fixture Movie', + backdrop_path: '/fixture-backdrop.jpg', + release_date: '2026-01-01', + }, + ], + }; + } + ); const res = await request(app).get('/api/v1/backdrops'); assert.notStrictEqual(res.status, 403); + assert.strictEqual(res.status, 200); + assert.deepStrictEqual(res.body, [ + { + path: '/fixture-backdrop.jpg', + title: 'Fixture Movie', + mediaType: 'movie', + year: '2026', + }, + ]); + assert.strictEqual(get.mock.callCount(), 1); + assert.strictEqual(calls[0][0], '/trending/all/week'); + assert.strictEqual( + (calls[0][1] as { params: { page: number } }).params.page, + 1 + ); }); it('rejects malformed keyword detail IDs before provider lookup', async () => { diff --git a/server/routes/movie.test.ts b/server/routes/movie.test.ts index c79284c76..7e8a678cd 100644 --- a/server/routes/movie.test.ts +++ b/server/routes/movie.test.ts @@ -1,8 +1,10 @@ import assert from 'node:assert/strict'; import { afterEach, before, describe, it, mock } from 'node:test'; -import ExternalAPI from '@server/api/externalapi'; import RadarrAPI from '@server/api/servarr/radarr'; +import TheMovieDb from '@server/api/themoviedb'; +import Tvdb from '@server/api/tvdb'; +import WikidataVideoMetadataAPI from '@server/api/wikidata/videoMetadata'; import { MediaStatus, MediaType } from '@server/constants/media'; import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; @@ -45,39 +47,72 @@ const mockPrivate = ( describe('GET /movie/:id', () => { it('retains the TMDB poster for an available movie linked to Radarr', async () => { - mockPrivate(ExternalAPI.prototype, 'get', async () => ({ - id: 100, - adult: false, - budget: 0, - genres: [], - videos: { results: [] }, - original_language: 'en', - original_title: 'Test Movie', - popularity: 0, - production_companies: [], - production_countries: [], - release_date: '2026-01-01', - release_dates: { results: [] }, - revenue: 0, - spoken_languages: [], - status: 'Released', - title: 'Test Movie', - video: false, - vote_average: 0, - vote_count: 0, - backdrop_path: '/provider-backdrop.jpg', - homepage: '', - imdb_id: 'tt0000100', - overview: 'A test movie.', - poster_path: '/provider-poster.jpg', - runtime: 90, - tagline: '', - credits: { cast: [], crew: [] }, - belongs_to_collection: null, - external_ids: {}, - keywords: { keywords: [] }, - 'watch/providers': { results: {} }, + const tmdbCalls: unknown[] = []; + mockPrivate(TheMovieDb.prototype, 'get', async (endpoint: unknown) => { + tmdbCalls.push(endpoint); + assert.strictEqual(endpoint, '/movie/100'); + return { + id: 100, + adult: false, + budget: 0, + genres: [], + videos: { results: [] }, + original_language: 'en', + original_title: 'Test Movie', + popularity: 0, + production_companies: [], + production_countries: [], + release_date: '2026-01-01', + release_dates: { results: [] }, + revenue: 0, + spoken_languages: [], + status: 'Released', + title: 'Test Movie', + video: false, + vote_average: 0, + vote_count: 0, + backdrop_path: '/provider-backdrop.jpg', + homepage: '', + imdb_id: 'tt0000100', + overview: 'A test movie.', + poster_path: '/provider-poster.jpg', + runtime: 90, + tagline: '', + credits: { cast: [], crew: [] }, + belongs_to_collection: null, + external_ids: {}, + keywords: { keywords: [] }, + 'watch/providers': { results: {} }, + }; + }); + const tvdb = new Tvdb(); + const getTvdb = mock.method(Tvdb, 'getInstance', async () => tvdb); + const getTvdbMetadata = mock.method( + tvdb, + 'getVideoMetadataByTmdbId', + async () => ({ + id: 200, + name: 'Test Movie', + releaseDate: '2026-01-01', + remoteIds: [{ sourceName: 'The Movie Database', id: '100' }], + }) + ); + const getTvdbById = mock.method(tvdb, 'getVideoMetadataById', async () => ({ + id: 200, + name: 'Test Movie', + releaseDate: '2026-01-01', + remoteIds: [{ sourceName: 'The Movie Database', id: '100' }], })); + const wikidataIds = mock.method( + WikidataVideoMetadataAPI.prototype, + 'searchItemsByExternalId', + async () => [] + ); + const wikidataTitle = mock.method( + WikidataVideoMetadataAPI.prototype, + 'searchItems', + async () => [] + ); const media = await getRepository(Media).save( new Media({ tmdbId: 100, @@ -94,6 +129,27 @@ describe('GET /movie/:id', () => { assert.strictEqual(res.status, 200); assert.strictEqual(res.body.mediaInfo.id, media.id); assert.strictEqual(res.body.posterPath, '/provider-poster.jpg'); + assert.deepStrictEqual(tmdbCalls, ['/movie/100']); + assert.strictEqual(getTvdb.mock.callCount(), 2); + assert.deepStrictEqual( + getTvdbMetadata.mock.calls.map((call) => call.arguments), + [[{ mediaType: 'movie', tmdbId: 100 }]] + ); + assert.deepStrictEqual( + getTvdbById.mock.calls.map((call) => call.arguments), + [[{ mediaType: 'movie', id: 200 }]] + ); + assert.deepStrictEqual( + wikidataIds.mock.calls.map((call) => call.arguments), + [ + [{ propertyId: 'P4947', value: '100' }], + [{ propertyId: 'P4947', value: '100' }], + ] + ); + assert.deepStrictEqual( + wikidataTitle.mock.calls.map((call) => call.arguments), + [['Test Movie']] + ); }); }); diff --git a/server/routes/music.test.ts b/server/routes/music.test.ts index 7e6712f6f..34525a5c7 100644 --- a/server/routes/music.test.ts +++ b/server/routes/music.test.ts @@ -4,6 +4,7 @@ import { afterEach, before, describe, it, mock } from 'node:test'; import CoverArtArchive from '@server/api/coverartarchive'; import ListenBrainzAPI from '@server/api/listenbrainz'; import MusicBrainz from '@server/api/musicbrainz'; +import LidarrAPI from '@server/api/servarr/lidarr'; import TheAudioDb from '@server/api/theaudiodb'; import { IssueStatus, IssueType } from '@server/constants/issue'; import { @@ -161,6 +162,51 @@ const albumDetails = { }, }; +// Successful ListenBrainz detail fixtures still ask MusicBrainz for taxonomy. +// Model an explicitly unavailable supplemental record, not a swallowed socket error. +function mockAlbumEnrichment( + mbId: string, + options: { taxonomy?: boolean; artwork?: boolean } = {} +) { + const assertions: (() => void)[] = []; + if (options.taxonomy !== false) { + const taxonomy = mock.method( + MusicBrainz.prototype, + 'getReleaseGroupDetails', + async () => { + throw new Error( + '[MusicBrainz] Fixture release group unavailable: status code 404' + ); + } + ); + assertions.push(() => + assert.deepStrictEqual( + taxonomy.mock.calls.map((call) => call.arguments), + [[{ releaseGroupId: mbId }]] + ) + ); + } + if (options.artwork !== false) { + const artwork = mock.method( + CoverArtArchive.prototype, + 'getCoverArt', + async () => ({ images: [], release: `/release/${mbId}` }) + ); + assertions.push(() => + assert.deepStrictEqual( + artwork.mock.calls.map((call) => call.arguments), + [[mbId]] + ) + ); + } + // Assert before afterEach restores spies; Vitest clears their call history. + return () => { + for (const assertion of assertions) { + assertion(); + } + }; +} + describe('GET /music/:id artist lists', () => { it('rejects malformed album IDs before artist discography provider lookup', async () => { const getAlbum = mock.method(ListenBrainzAPI.prototype, 'getAlbum'); @@ -313,6 +359,7 @@ describe('GET /music/:id', () => { }); it('returns album details when optional ListenBrainz stats and tags are absent', async () => { + const assertEnrichment = mockAlbumEnrichment('release-group-id'); mock.method(ListenBrainzAPI.prototype, 'getAlbum', async () => ({ release_group_mbid: 'release-group-id', type: 'Album', @@ -349,9 +396,13 @@ describe('GET /music/:id', () => { assert.deepStrictEqual(res.body.tags.artist, []); assert.deepStrictEqual(res.body.stats.listeners, []); assert.deepStrictEqual(res.body.tracks[0].artists, []); + assertEnrichment(); }); it('includes release labels when MusicBrainz exposes them', async () => { + const assertEnrichment = mockAlbumEnrichment('release-group-id', { + artwork: false, + }); const releaseId = '00000000-0000-0000-0000-000000000001'; mock.method(ListenBrainzAPI.prototype, 'getAlbum', async () => ({ caa_release_mbid: releaseId, @@ -415,9 +466,18 @@ describe('GET /music/:id', () => { res.body.recordLabel, 'Example Records, Example Records Publishing' ); + assertEnrichment(); }); it('falls back to MusicBrainz when ListenBrainz has no album detail page', async () => { + const assertEnrichment = mockAlbumEnrichment('release-group-id', { + taxonomy: false, + }); + const images = mock.method( + TheAudioDb.prototype, + 'getArtistImages', + async () => ({ artistThumb: null, artistBackground: null }) + ); mock.method(ListenBrainzAPI.prototype, 'getAlbum', async () => { throw new Error('[ListenBrainz] Failed to fetch album details: 404'); }); @@ -457,9 +517,21 @@ describe('GET /music/:id', () => { assert.deepStrictEqual(res.body.tags.releaseGroup, [ { count: 5, genreMbid: '', tag: 'jazz' }, ]); + assert.deepStrictEqual( + images.mock.calls.map((call) => call.arguments), + [['artist-id']] + ); + assertEnrichment(); }); it('returns the normalized MusicBrainz release-group rating and vote count', async () => { + const audioRating = mock.method( + TheAudioDb.prototype, + 'getAlbumRating', + async () => { + throw new Error('Fixture TheAudioDb rating unavailable'); + } + ); mock.method( MusicBrainz.prototype, 'getReleaseGroupDetails', @@ -486,6 +558,10 @@ describe('GET /music/:id', () => { const res = await agent.get('/music/release-group-id/rating'); assert.strictEqual(res.status, 200); + assert.deepStrictEqual( + audioRating.mock.calls.map((call) => call.arguments), + [['release-group-id']] + ); assert.deepStrictEqual(res.body, { rating: { score: 8.5, @@ -523,6 +599,7 @@ describe('GET /music/:id', () => { }); it('filters saved media request users from music detail responses', async () => { + const assertEnrichment = mockAlbumEnrichment('release-group-id'); mock.method(ListenBrainzAPI.prototype, 'getAlbum', async () => ({ release_group_mbid: 'release-group-id', type: 'Album', @@ -561,9 +638,45 @@ describe('GET /music/:id', () => { assert.strictEqual(res.status, 200); assert.strictEqual(res.body.mediaInfo.mbId, 'release-group-id'); + assertEnrichment(); }); it('returns every available Lidarr quality without exposing completed requests', async (t) => { + const assertEnrichment = mockAlbumEnrichment('quality-release-group-id'); + const albums = mock.method(LidarrAPI.prototype, 'getAlbums', async () => [ + { + id: 10, + mbId: 'quality-release-group-id', + foreignAlbumId: 'quality-release-group-id', + title: 'Quality Album', + monitored: true, + artistId: 1, + titleSlug: 'quality-album', + profileId: 1, + duration: 180000, + albumType: 'Album', + statistics: { + trackFileCount: 0, + trackCount: 1, + totalTrackCount: 1, + sizeOnDisk: 0, + percentOfTracks: 0, + }, + }, + ]); + const tracks = mock.method(LidarrAPI.prototype, 'getTracks', async () => [ + { + id: 1, + albumId: 10, + title: 'Quality Track', + trackNumber: '1', + absoluteTrackNumber: 1, + mediumNumber: 1, + hasFile: false, + trackFileId: 0, + foreignRecordingId: 'quality-recording-id', + }, + ]); mock.method(ListenBrainzAPI.prototype, 'getAlbum', async () => ({ release_group_mbid: 'quality-release-group-id', type: 'Album', @@ -664,14 +777,28 @@ describe('GET /music/:id', () => { const res = await agent.get('/music/quality-release-group-id'); assert.strictEqual(res.status, 200); + assert.deepStrictEqual( + albums.mock.calls.map((call) => call.arguments), + [[300], [300]] + ); + assert.deepStrictEqual( + tracks.mock.calls.map((call) => call.arguments), + [ + [{ albumId: 10 }, 300], + [{ albumId: 10 }, 300], + ] + ); + assert.deepStrictEqual(res.body.trackAvailability, { mp3: [], flac: [] }); assert.deepStrictEqual(res.body.availableServices, [ { serverId: 1, quality: 'MP3' }, { serverId: 2, quality: 'FLAC' }, ]); assert.strictEqual(res.body.mediaInfo.requests.length, 0); + assertEnrichment(); }); it('hydrates independent request and issue trees without dropping detail state', async () => { + const assertEnrichment = mockAlbumEnrichment('release-group-id'); mock.method(ListenBrainzAPI.prototype, 'getAlbum', async () => ({ release_group_mbid: 'release-group-id', type: 'Album', @@ -730,5 +857,6 @@ describe('GET /music/:id', () => { res.body.mediaInfo.issues[0].comments[0].message, 'Independent issue comment' ); + assertEnrichment(); }); }); diff --git a/server/routes/request.test.ts b/server/routes/request.test.ts index 235250f9b..f818a22f3 100644 --- a/server/routes/request.test.ts +++ b/server/routes/request.test.ts @@ -6585,6 +6585,19 @@ describe('POST /request/:requestId/retry', () => { assert.strictEqual(res.status, 200); assert.strictEqual(res.body.status, MediaRequestStatus.APPROVED); assert.strictEqual(res.body.modifiedBy.id, 2); + const retryEvents = await getRepository(MediaRequestStatusEvent).find({ + where: { requestId: failed.id }, + order: { id: 'ASC' }, + }); + const retryEvent = retryEvents.find((event) => + event.fingerprint.startsWith('retry:') + ); + assert.ok(retryEvent); + assert.strictEqual(retryEvent.stage, RequestStatusStage.APPROVED); + assert.strictEqual( + retryEvent.message, + 'The request was retried and is waiting to be dispatched.' + ); }); it('requeues an unavailable approved request for another search', async () => { diff --git a/server/routes/request.ts b/server/routes/request.ts index 956ad5f96..d3c99fe58 100644 --- a/server/routes/request.ts +++ b/server/routes/request.ts @@ -64,6 +64,7 @@ import { RequestStatusStage, getRequestStatusHistory, getRequestStatusPage, + recordRequestRetry, recordRequestStatus, } from '@server/lib/requestStatus'; import { @@ -4365,6 +4366,7 @@ requestRoutes.post<{ request.modifiedBy = actor; await requestRepository.save(request); await requestDispatchManager.enqueue(request.id); + await recordRequestRetry(request.id); return res .status(200) diff --git a/server/routes/settingsIndex.test.ts b/server/routes/settingsIndex.test.ts index 00b0a90bf..3e916932c 100644 --- a/server/routes/settingsIndex.test.ts +++ b/server/routes/settingsIndex.test.ts @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import dns from 'node:dns/promises'; import path from 'node:path'; import { afterEach, before, beforeEach, describe, it, mock } from 'node:test'; @@ -29,6 +30,7 @@ import { } from '@server/lib/settings'; import { runUserSecurityMutation } from '@server/lib/userSecurityMutation'; import { setupTestDb } from '@server/test/db'; +import { isSafeHttpUrl } from '@server/utils/security'; import type { Express } from 'express'; import express from 'express'; import * as OpenApiValidator from 'express-openapi-validator'; @@ -119,6 +121,14 @@ before(() => { }); beforeEach(() => { + // Notification transports are mocked; their explicit public URLs need DNS too. + mock.method(dns, 'lookup', async (hostname: string, options: unknown) => { + assert.deepStrictEqual(options, { all: true }); + if (hostname !== 'example.com') { + throw new Error(`Unconfigured fixture DNS hostname: ${hostname}`); + } + return [{ address: '93.184.216.34', family: 4 }]; + }); const settings = getSettings(); settings.plex.libraries = [ { id: '1', name: 'Movies', enabled: false, type: 'movie' }, @@ -135,6 +145,15 @@ afterEach(() => { }); describe('Settings route input validation', () => { + it('isolates public fixture DNS without admitting unknown or private hosts', async () => { + assert.strictEqual(await isSafeHttpUrl('https://example.com/'), true); + assert.strictEqual( + await isSafeHttpUrl('https://unconfigured.example.com/'), + false + ); + assert.strictEqual(await isSafeHttpUrl('https://127.0.0.1/'), false); + }); + it('revalidates administrator authority before applying settings', async () => { const settings = getSettings(); const originalTitle = settings.main.applicationTitle; diff --git a/server/routes/software.test.ts b/server/routes/software.test.ts index d7a0133ab..e74024722 100644 --- a/server/routes/software.test.ts +++ b/server/routes/software.test.ts @@ -164,6 +164,7 @@ const createOpenApiValidatedSettingsApp = (): Express => { }; const createSoftwareRequest = async (options: { + category?: 'retro' | 'modern' | 'game'; provider?: SoftwareRequestProvider; status?: SoftwareRequestStatus; requestedById?: number; @@ -173,7 +174,7 @@ const createSoftwareRequest = async (options: { return repository.save( repository.create({ requestedById: options.requestedById ?? 2, - category: 'retro', + category: options.category ?? 'retro', provider: options.provider ?? 'romarr', status: options.status ?? 'failed', externalRequestId: options.externalRequestId ?? 'seerrng:software:test', @@ -210,6 +211,42 @@ afterEach(() => { }); describe('software request routes', () => { + it('filters software requests by category before pagination', async () => { + await createSoftwareRequest({ + category: 'retro', + externalRequestId: 'seerrng:software:category-retro', + }); + await createSoftwareRequest({ + category: 'modern', + externalRequestId: 'seerrng:software:category-modern', + }); + await createSoftwareRequest({ + category: 'game', + externalRequestId: 'seerrng:software:category-game', + }); + + const response = await request(createApp()) + .get('/request/software/status') + .query({ category: 'modern', take: 1, skip: 0 }); + + assert.strictEqual(response.status, 200); + assert.strictEqual(response.body.results.length, 1); + assert.strictEqual(response.body.results[0].request.category, 'modern'); + assert.strictEqual(response.body.pageInfo.pages, 1); + assert.strictEqual(response.body.pageInfo.results, 1); + }); + + it('rejects unknown software request categories', async () => { + const response = await request(createApp()) + .get('/request/software/status') + .query({ category: 'arcade' }); + + assert.strictEqual(response.status, 400); + assert.deepStrictEqual(response.body, { + error: 'Invalid software category.', + }); + }); + it('lets the requester clear a cancelled request and its status history', async () => { const saved = await createSoftwareRequest({ status: 'cancelled', diff --git a/server/routes/software.ts b/server/routes/software.ts index 9d59688ba..06d1a43d0 100644 --- a/server/routes/software.ts +++ b/server/routes/software.ts @@ -1247,6 +1247,7 @@ softwareRoutes.get('/status', async (req, res) => { const requestId = req.query.requestId === undefined ? undefined : Number(req.query.requestId); const rawFilter = req.query.filter; + const rawCategory = req.query.category; if ( rawFilter !== undefined && (typeof rawFilter !== 'string' || @@ -1258,6 +1259,14 @@ softwareRoutes.get('/status', async (req, res) => { } const filter = (rawFilter ?? 'all') as (typeof softwareStatusFilters)[number]; const statuses = getStatusesForFilter(filter); + if ( + rawCategory !== undefined && + (typeof rawCategory !== 'string' || + !['retro', 'modern', 'game'].includes(rawCategory)) + ) { + return res.status(400).json({ error: 'Invalid software category.' }); + } + const category = rawCategory as SoftwareRequestCategory | undefined; if ( requestedBy !== undefined && (!Number.isSafeInteger(requestedBy) || requestedBy <= 0) @@ -1289,6 +1298,9 @@ softwareRoutes.get('/status', async (req, res) => { if (requestId !== undefined) { query.andWhere('request.id = :requestId', { requestId }); } + if (category !== undefined) { + query.andWhere('request.category = :category', { category }); + } if (statuses?.length) { query.andWhere('request.status IN (:...statuses)', { statuses }); } else if (statuses) { diff --git a/server/routes/tv.ts b/server/routes/tv.ts index 11c04c379..4ab8884d9 100644 --- a/server/routes/tv.ts +++ b/server/routes/tv.ts @@ -44,8 +44,12 @@ import { } from '@server/utils/validation'; import { Router } from 'express'; import rateLimit from 'express-rate-limit'; +import tvCollectionsRoutes from './tvCollections'; +import tvSavedItemRoutes from './tvSavedItem'; const tvRoutes = Router(); +tvRoutes.use(tvSavedItemRoutes); +tvRoutes.use(tvCollectionsRoutes); const maxTmdbTvId = 1_000_000_000; const maxTvSeasonNumber = 10_000; const maxShuffleSeedLength = 128; diff --git a/server/routes/tvCollections.ts b/server/routes/tvCollections.ts new file mode 100644 index 000000000..bf398858e --- /dev/null +++ b/server/routes/tvCollections.ts @@ -0,0 +1,241 @@ +import { + getMediaServerSeriesCollections, + isMediaServerCollectionId, + setMediaServerSeriesCollectionMembership, +} from '@server/api/mediaServerCollections'; +import { PlexCollectionAuthorityError } from '@server/api/plexCollections'; +import { MediaType } from '@server/constants/media'; +import { MediaServerType } from '@server/constants/server'; +import { getRepository } from '@server/datasource'; +import Media from '@server/entity/Media'; +import { getPlaybackMediaRootId } from '@server/lib/playbackMediaRoot'; +import { getSettings } from '@server/lib/settings'; +import { + runUserSecurityMutationWithActor, + runUserSecurityReadWithActor, + UserMutationActorUnauthorizedError, +} from '@server/lib/userSecurityMutation'; +import type { + MediaServerCollectionContext, + MediaServerCollectionsStatus, +} from '@server/models/MediaServerCollections'; +import { parsePositiveRouteId } from '@server/utils/routeId'; +import { getRateLimitKey } from '@server/utils/security'; +import { Router, type RequestHandler } from 'express'; +import rateLimit from 'express-rate-limit'; + +const routes = Router(); +const collectionRateLimit = rateLimit({ + windowMs: 60_000, + limit: 20, + standardHeaders: true, + legacyHeaders: false, + keyGenerator: (req) => + req.user?.id ? `user:${req.user.id}` : getRateLimitKey(req), + skip: () => process.env.NODE_ENV === 'test', +}); +const record = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value); +const nativeAccountActorOptions = { + includeMediaServerCredentials: true, +} as const; +const serverAuthorityKey = (): string => { + const settings = getSettings(); + const serverType = settings.main.mediaServerType; + const config = + serverType === MediaServerType.PLEX ? settings.plex : settings.jellyfin; + return JSON.stringify({ + serverType, + ip: config.ip, + port: config.port, + useSsl: config.useSsl, + urlBase: 'urlBase' in config ? config.urlBase : undefined, + serverId: 'serverId' in config ? config.serverId : config.machineId, + }); +}; + +const handleCollections: RequestHandler = async (req, res) => { + if (!req.user) + return res.status(401).json({ message: 'Authentication required.' }); + const actorId = req.user.id; + const id = parsePositiveRouteId(req.params.id, 1_000_000_000); + if (!id) return res.status(400).json({ message: 'Invalid series ID.' }); + const updating = req.method === 'POST'; + // OpenAPI validates/coerces this value and inserts the false query default. + // Exact strings remain valid for direct router use, without truthy coercion. + const queryQuality: unknown = req.query.is4k; + if ( + Object.keys(req.query).some((key) => key !== 'is4k') || + (updating && Object.keys(req.query).length !== 0) || + (!updating && + queryQuality !== undefined && + typeof queryQuality !== 'boolean' && + queryQuality !== 'true' && + queryQuality !== 'false') + ) + return res + .status(400) + .json({ message: 'Provide only a boolean quality selection.' }); + if ( + updating + ? !record(req.body) || + Object.keys(req.body).some( + (key) => key !== 'member' && key !== 'is4k' + ) || + typeof req.body.member !== 'boolean' || + (req.body.is4k !== undefined && typeof req.body.is4k !== 'boolean') + : req.body !== undefined && + (!record(req.body) || Object.keys(req.body).length !== 0) + ) + return res.status(400).json({ + message: + 'Provide only the desired membership and optional boolean quality selection.', + }); + const collectionId = updating ? req.params.collectionId : undefined; + if ( + updating && + !isMediaServerCollectionId(getSettings().main.mediaServerType, collectionId) + ) + return res.status(400).json({ message: 'Invalid collection ID.' }); + const is4k = updating + ? req.body.is4k === true + : queryQuality === true || queryQuality === 'true'; + const member = updating ? (req.body.member as boolean) : undefined; + try { + const withAuthority = updating + ? runUserSecurityMutationWithActor + : runUserSecurityReadWithActor; + const status = await withAuthority( + actorId, + actorId, + [], + async (actor): Promise => { + // The authority helper supplies the current persisted actor while writes + // serialize unlink/credential changes. Ignore req.user's stale token fields. + if (actor.id !== actorId) + throw new UserMutationActorUnauthorizedError( + 'Account authority changed.' + ); + const serverType = getSettings().main.mediaServerType; + const unavailable = ( + reason: NonNullable + ): MediaServerCollectionsStatus => ({ + serverType, + available: false, + reason, + collections: [], + }); + if ( + ![ + MediaServerType.PLEX, + MediaServerType.JELLYFIN, + MediaServerType.EMBY, + ].includes(serverType) + ) + return unavailable('unsupported-server'); + if ( + serverType === MediaServerType.PLEX + ? !actor.plexToken || !actor.plexId + : !actor.jellyfinAuthToken || !actor.jellyfinUserId + ) + return unavailable('account-not-linked'); + if (updating && !isMediaServerCollectionId(serverType, collectionId)) + return unavailable('not-authorized'); + const authorityKey = serverAuthorityKey(); + const account = { + plexId: actor.plexId, + plexToken: actor.plexToken, + jellyfinUserId: actor.jellyfinUserId, + jellyfinAuthToken: actor.jellyfinAuthToken, + }; + const media = await getRepository(Media).findOne({ + where: { tmdbId: id, mediaType: MediaType.TV }, + }); + // Reads cannot hold the mutation lock throughout remote/database work. + // Re-admit current actor authority after resolving the stored Series; + // writes retain their outer mutation lock while this read revalidates it. + const currentActor = await runUserSecurityReadWithActor( + actorId, + actorId, + [], + async (current) => current, + nativeAccountActorOptions + ); + if ( + serverAuthorityKey() !== authorityKey || + currentActor.id !== actorId || + currentActor.plexId !== account.plexId || + currentActor.plexToken !== account.plexToken || + currentActor.jellyfinUserId !== account.jellyfinUserId || + currentActor.jellyfinAuthToken !== account.jellyfinAuthToken + ) + throw new UserMutationActorUnauthorizedError( + 'Account or media-server authority changed.' + ); + const itemId = media && getPlaybackMediaRootId(media, serverType, is4k); + if ( + !media || + media.tmdbId !== id || + media.mediaType !== MediaType.TV || + !itemId + ) + return unavailable('series-not-found'); + const context: MediaServerCollectionContext = { + user: { + id: currentActor.id, + plexId: currentActor.plexId, + plexToken: currentActor.plexToken, + jellyfinUserId: currentActor.jellyfinUserId, + jellyfinAuthToken: currentActor.jellyfinAuthToken, + jellyfinDeviceId: currentActor.jellyfinDeviceId, + }, + tmdbId: id, + itemId, + is4k, + }; + return updating + ? setMediaServerSeriesCollectionMembership( + context, + collectionId as string, + member as boolean + ) + : getMediaServerSeriesCollections(context); + }, + nativeAccountActorOptions + ); + if (updating && !status.available) + return res.status(409).json({ + message: + 'This series cannot be added to or removed from the selected media-server collection.', + }); + return res.json(status); + } catch (error) { + if (error instanceof UserMutationActorUnauthorizedError) + return res + .status(403) + .json({ message: 'Account authority changed. Sign in again.' }); + if (error instanceof PlexCollectionAuthorityError) + return res.status(403).json({ + message: + 'The linked account cannot modify this media-server collection.', + }); + // Native errors may contain credential-bearing URLs or request headers. + // Never echo/log them or claim success after a partial provider failure. + return res.status(502).json({ + message: + 'The media-server collections could not be loaded or updated. Please retry.', + }); + } +}; + +routes.get( + '/:id/media-server-collections', + collectionRateLimit, + handleCollections +); +routes.post( + '/:id/media-server-collections/:collectionId', + collectionRateLimit, + handleCollections +); +export default routes; diff --git a/server/routes/tvSavedItem.ts b/server/routes/tvSavedItem.ts new file mode 100644 index 000000000..4e4b85a53 --- /dev/null +++ b/server/routes/tvSavedItem.ts @@ -0,0 +1,263 @@ +import { + FavoriteSeriesAPI, + getPlexWatchlistDiagnostic, + PlexSavedItemAPI, + PlexWatchlistError, + runPlexWatchlistPhase, +} from '@server/api/mediaServerSavedItem'; +import { getMetadataProvider } from '@server/api/metadata'; +import { MediaType } from '@server/constants/media'; +import { MediaServerType } from '@server/constants/server'; +import { getRepository } from '@server/datasource'; +import Media from '@server/entity/Media'; +import { getPlaybackMediaRootId } from '@server/lib/playbackMediaRoot'; +import { getSettings } from '@server/lib/settings'; +import { + runUserSecurityMutationWithActor, + runUserSecurityReadWithActor, + UserMutationActorUnauthorizedError, +} from '@server/lib/userSecurityMutation'; +import logger from '@server/logger'; +import type { MediaServerSavedItemStatus } from '@server/models/MediaServerSavedItem'; +import { getHostname } from '@server/utils/getHostname'; +import { parsePositiveRouteId } from '@server/utils/routeId'; +import { getRateLimitKey } from '@server/utils/security'; +import { Router } from 'express'; +import rateLimit from 'express-rate-limit'; + +const routes = Router(); +const record = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value); +const nativeAccountActorOptions = { + includeMediaServerCredentials: true, +} as const; +const serverAuthorityKey = (): string => { + const settings = getSettings(); + const serverType = settings.main.mediaServerType; + const config = + serverType === MediaServerType.PLEX ? settings.plex : settings.jellyfin; + return JSON.stringify({ + serverType, + ip: config.ip, + port: config.port, + useSsl: config.useSsl, + urlBase: 'urlBase' in config ? config.urlBase : undefined, + serverId: 'serverId' in config ? config.serverId : config.machineId, + }); +}; +const savedItemRateLimit = rateLimit({ + windowMs: 60_000, + limit: 20, + standardHeaders: true, + legacyHeaders: false, + keyGenerator: (req) => + req.user?.id ? `user:${req.user.id}` : getRateLimitKey(req), + skip: () => process.env.NODE_ENV === 'test', +}); + +routes.all( + '/:id/media-server-saved-item', + savedItemRateLimit, + async (req, res, next) => { + if (req.method !== 'GET' && req.method !== 'POST') return next(); + if (!req.user) + return res.status(401).json({ message: 'Authentication required.' }); + const actorId = req.user.id; + const id = parsePositiveRouteId(req.params.id, 1_000_000_000); + if (!id) return res.status(400).json({ message: 'Invalid series ID.' }); + const updating = req.method === 'POST'; + // OpenAPI validation coerces the query to a boolean and inserts its default. + // Also accept exact strings for direct router use; never coerce truthiness. + const queryQuality: unknown = req.query.is4k; + if ( + Object.keys(req.query).some((key) => key !== 'is4k') || + (updating && queryQuality !== undefined) || + (!updating && + queryQuality !== undefined && + typeof queryQuality !== 'boolean' && + queryQuality !== 'true' && + queryQuality !== 'false') + ) + return res + .status(400) + .json({ message: 'Provide only a boolean quality selection.' }); + if ( + updating + ? !record(req.body) || + Object.keys(req.body).some( + (key) => key !== 'saved' && key !== 'is4k' + ) || + typeof req.body.saved !== 'boolean' || + (req.body.is4k !== undefined && typeof req.body.is4k !== 'boolean') + : req.body !== undefined && + (!record(req.body) || Object.keys(req.body).length !== 0) + ) { + return res.status(400).json({ + message: + 'Provide only the desired saved state and optional boolean quality selection.', + }); + } + const is4k = updating + ? req.body.is4k === true + : queryQuality === true || queryQuality === 'true'; + try { + // Writes serialize account unlink/credential changes; reads do not hold the + // mutation lock during catalog fetching. Both revalidate current authority. + // Never use req.user's stale token or shared server credentials. + const withAuthority = updating + ? runUserSecurityMutationWithActor + : runUserSecurityReadWithActor; + const status = await withAuthority( + actorId, + actorId, + [], + async (user) => { + if (user.id !== actorId) + throw new UserMutationActorUnauthorizedError( + 'Account authority changed.' + ); + const settings = getSettings(); + const serverType = settings.main.mediaServerType; + const authorityKey = serverAuthorityKey(); + const account = { + plexId: user.plexId, + plexToken: user.plexToken, + jellyfinUserId: user.jellyfinUserId, + jellyfinAuthToken: user.jellyfinAuthToken, + }; + const revalidateAuthority = async () => { + const current = await runUserSecurityReadWithActor( + actorId, + actorId, + [], + async (actor) => actor, + nativeAccountActorOptions + ); + if ( + current.id !== actorId || + serverAuthorityKey() !== authorityKey || + current.plexId !== account.plexId || + current.plexToken !== account.plexToken || + current.jellyfinUserId !== account.jellyfinUserId || + current.jellyfinAuthToken !== account.jellyfinAuthToken + ) + throw new UserMutationActorUnauthorizedError( + 'Account or media-server authority changed.' + ); + }; + const base: MediaServerSavedItemStatus = { + serverType, + kind: + serverType === MediaServerType.PLEX ? 'watchlist' : 'favorites', + available: false, + }; + if (serverType === MediaServerType.PLEX) { + if (!user.plexToken || !user.plexId) + return { ...base, reason: 'account-not-linked' as const }; + const series = await runPlexWatchlistPhase( + 'series-metadata', + async () => { + const metadata = await getMetadataProvider('tv'); + return metadata.getTvShow({ tvId: id }); + } + ); + await revalidateAuthority(); + if (series.id !== id || !series.name?.trim()) + return { ...base, reason: 'series-not-found' as const }; + const provider = new PlexSavedItemAPI(account.plexToken!); + await provider.verifyAccount(account.plexId!); + const catalogId = await provider.resolveSeries(id, series.name); + await revalidateAuthority(); + if (!catalogId) + return { ...base, reason: 'series-not-found' as const }; + const saved = await provider.getSaved(catalogId); + await revalidateAuthority(); + if (updating && saved !== req.body.saved) { + await provider.setSaved(catalogId, req.body.saved); + if ( + (await provider.getSaved( + catalogId, + 'watchlist-confirmation' + )) !== req.body.saved + ) + throw new PlexWatchlistError( + 'watchlist-confirmation', + 'confirmation-mismatch' + ); + await revalidateAuthority(); + } + return { + ...base, + available: true, + saved: updating ? req.body.saved : saved, + }; + } + if ( + serverType === MediaServerType.JELLYFIN || + serverType === MediaServerType.EMBY + ) { + if (!user.jellyfinUserId || !user.jellyfinAuthToken) + return { ...base, reason: 'account-not-linked' as const }; + const media = await getRepository(Media).findOne({ + where: { tmdbId: id, mediaType: MediaType.TV }, + }); + await revalidateAuthority(); + if ( + !media || + media.tmdbId !== id || + media.mediaType !== MediaType.TV + ) + return { ...base, reason: 'series-not-found' as const }; + const itemId = getPlaybackMediaRootId(media, serverType, is4k); + if (!itemId) + return { ...base, reason: 'series-not-found' as const }; + const provider = new FavoriteSeriesAPI( + getHostname(settings.jellyfin), + account.jellyfinAuthToken!, + account.jellyfinUserId!, + { serverType, trustedAccountPair: true } + ); + await provider.verifyAccount(); + const saved = await provider.getSeriesSaved(itemId, id); + await revalidateAuthority(); + const confirmed = + updating && saved !== req.body.saved + ? await provider.setSaved(itemId, req.body.saved) + : saved; + if (updating && saved !== req.body.saved) + await revalidateAuthority(); + return { ...base, available: true, saved: confirmed }; + } + return base; + }, + nativeAccountActorOptions + ); + if (updating && !status.available) + return res.status(409).json({ + message: + 'This series cannot be saved to the linked media-server account.', + }); + return res.json(status); + } catch (error) { + if (error instanceof UserMutationActorUnauthorizedError) + return res + .status(403) + .json({ message: 'Account authority changed. Sign in again.' }); + // Provider errors can contain tokens/URLs. Never echo or log their details. + const diagnostic = getPlexWatchlistDiagnostic(error); + if (diagnostic) { + logger.warn('Plex Watchlist operation failed.', { + label: 'Plex Watchlist Diagnostics', + ...diagnostic, + }); + } + return res.status(502).json({ + message: + 'The media-server saved state could not be loaded or updated. Please retry.', + ...(diagnostic ? { diagnostic } : {}), + }); + } + } +); + +export default routes; diff --git a/server/routes/user.test.ts b/server/routes/user.test.ts index 2d56af736..343c286b2 100644 --- a/server/routes/user.test.ts +++ b/server/routes/user.test.ts @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; -import { before, describe, it, mock } from 'node:test'; +import dns from 'node:dns/promises'; +import { afterEach, before, beforeEach, describe, it, mock } from 'node:test'; import JellyfinAPI from '@server/api/jellyfin'; import PlexTvAPI from '@server/api/plextv'; @@ -18,6 +19,7 @@ import { getSettings } from '@server/lib/settings'; import { runUserSecurityMutation } from '@server/lib/userSecurityMutation'; import { checkUser } from '@server/middleware/auth'; import { setupTestDb } from '@server/test/db'; +import { isSafeHttpUrl } from '@server/utils/security'; import type { Express } from 'express'; import express from 'express'; import rateLimit from 'express-rate-limit'; @@ -120,6 +122,35 @@ async function loginAs(email: string, password: string) { } describe('User route input validation', () => { + let restoreFixtureDns = () => {}; + + beforeEach(() => { + // Keep push URL admission real while resolving only the explicit fixture host. + const lookup = mock.method( + dns, + 'lookup', + async (hostname: string, options: unknown) => { + assert.deepStrictEqual(options, { all: true }); + if (hostname !== 'example.com') { + throw new Error(`Unconfigured fixture DNS hostname: ${hostname}`); + } + return [{ address: '93.184.216.34', family: 4 }]; + } + ); + restoreFixtureDns = () => lookup.mock.restore(); + }); + + afterEach(() => restoreFixtureDns()); + + it('isolates public fixture DNS without admitting unknown or private hosts', async () => { + assert.strictEqual(await isSafeHttpUrl('https://example.com/'), true); + assert.strictEqual( + await isSafeHttpUrl('https://unconfigured.example.com/'), + false + ); + assert.strictEqual(await isSafeHttpUrl('https://127.0.0.1/'), false); + }); + it('bounds authenticated password mutation attempts', () => { assert.deepStrictEqual(PASSWORD_MUTATION_RATE_LIMIT, { windowMs: 15 * 60 * 1000, @@ -2520,6 +2551,8 @@ describe('User route input validation', () => { }); assert.strictEqual(tvSave.status, 200); assert.deepStrictEqual(tvSave.body, { + mediaServer: false, + overview: false, cast: false, crew: true, artists: false, @@ -2546,6 +2579,8 @@ describe('User route input validation', () => { details: false, }); assert.deepStrictEqual(user.settings?.detailDisclosurePins?.tv, { + mediaServer: false, + overview: false, cast: false, crew: true, artists: false, @@ -2554,6 +2589,51 @@ describe('User route input validation', () => { }); }); + it('persists the TV media server pin without changing other disclosure pins', async () => { + const agent = await loginAs('admin@seerr.dev', 'test1234'); + const initial = await agent.get('/user/1/settings/detail-disclosures/tv'); + assert.strictEqual(initial.body.mediaServer, false); + await agent + .post('/user/1/settings/detail-disclosures/movie') + .send({ cast: true }); + await agent + .post('/user/1/settings/detail-disclosures/tv') + .send({ crew: true }); + const saved = await agent + .post('/user/1/settings/detail-disclosures/tv') + .send({ mediaServer: true }); + assert.strictEqual(saved.status, 200); + assert.strictEqual(saved.body.mediaServer, true); + assert.strictEqual(saved.body.crew, true); + const fetched = await agent.get('/user/1/settings/detail-disclosures/tv'); + assert.deepStrictEqual(fetched.body, saved.body); + const movie = await agent.get('/user/1/settings/detail-disclosures/movie'); + assert.strictEqual(movie.body.cast, true); + assert.strictEqual(movie.body.mediaServer, undefined); + const invalid = await agent + .post('/user/1/settings/detail-disclosures/tv') + .send({ mediaServer: 'true' }); + assert.strictEqual(invalid.status, 400); + const unpinned = await agent + .post('/user/1/settings/detail-disclosures/tv') + .send({ mediaServer: false }); + assert.strictEqual(unpinned.body.mediaServer, false); + assert.strictEqual(unpinned.body.crew, true); + const user = await getRepository(User).findOneOrFail({ where: { id: 1 } }); + assert.strictEqual( + user.settings?.detailDisclosurePins?.tv?.mediaServer, + false + ); + }); + + it('does not allow another user to change the TV media server pin', async () => { + const agent = await loginAs('friend@seerr.dev', 'test1234'); + const response = await agent + .post('/user/1/settings/detail-disclosures/tv') + .send({ mediaServer: true }); + assert.strictEqual(response.status, 403); + }); + it('persists the movie collection pin without changing cast or other media pins', async () => { const agent = await loginAs('admin@seerr.dev', 'test1234'); const initial = await agent.get( diff --git a/server/routes/user/usersettings.ts b/server/routes/user/usersettings.ts index 3d00bed35..dc2694fe0 100644 --- a/server/routes/user/usersettings.ts +++ b/server/routes/user/usersettings.ts @@ -52,6 +52,11 @@ import { ApiError } from '@server/types/error'; import { isAvailableLocale } from '@server/types/languages'; import { validateAdvancedThemeOverrides } from '@server/utils/advancedThemeOverrides'; import AsyncLock from '@server/utils/asyncLock'; +import { + normalizeSeriesDisclosureOrder, + parseSeriesDisclosureOrder, + type SeriesDisclosureRole, +} from '@server/utils/detailDisclosureOrder'; import { normalizeDiscordSnowflake } from '@server/utils/discord'; import { getHostname } from '@server/utils/getHostname'; import { normalizeJellyfinGuid } from '@server/utils/jellyfin'; @@ -81,6 +86,104 @@ import { canMakePermissionsChange, isUniqueConstraintError } from '.'; const userSettingsRoutes = Router({ mergeParams: true }); +userSettingsRoutes.get< + { id: string; mediaType: string }, + SeriesDisclosureRole[] +>( + '/detail-disclosure-order/:mediaType', + isOwnProfile(), + async (req, res, next) => { + if (req.params.mediaType !== 'tv') + return next({ status: 400, message: 'Invalid detail media type.' }); + const userId = parsePositiveRouteId(req.params.id); + if (!userId) return next({ status: 404, message: 'User not found.' }); + try { + return await runUserSecurityReadWithActor( + req.user!.id, + userId, + Permission.MANAGE_USERS, + async () => { + const user = await getRepository(User).findOne({ + where: { id: userId }, + }); + if (!user) return next({ status: 404, message: 'User not found.' }); + return res + .status(200) + .json( + normalizeSeriesDisclosureOrder( + user.settings?.detailDisclosureOrder?.tv + ) + ); + } + ); + } catch (e) { + return next({ + status: e instanceof UserMutationActorUnauthorizedError ? 403 : 500, + message: + e instanceof UserMutationActorUnauthorizedError + ? 'Access denied.' + : e.message, + }); + } + } +); +userSettingsRoutes.post< + { id: string; mediaType: string }, + SeriesDisclosureRole[], + { order?: unknown } +>( + '/detail-disclosure-order/:mediaType', + isOwnProfile(), + async (req, res, next) => { + if (req.params.mediaType !== 'tv') + return next({ status: 400, message: 'Invalid detail media type.' }); + const order = + req.body && Object.keys(req.body).length === 1 + ? parseSeriesDisclosureOrder(req.body.order) + : null; + if (!order) + return next({ status: 400, message: 'Invalid disclosure order.' }); + const userId = parsePositiveRouteId(req.params.id); + if (!userId) return next({ status: 404, message: 'User not found.' }); + try { + return await runUserSecurityMutationWithActor( + req.user!.id, + userId, + Permission.MANAGE_USERS, + async (actor) => { + // Preferences are exclusively self-owned, including for administrators. + if (actor.id !== userId) + return next({ status: 403, message: 'Access denied.' }); + const repo = getRepository(User); + const user = await repo.findOne({ where: { id: userId } }); + if (!user) return next({ status: 404, message: 'User not found.' }); + if (!user.settings) user.settings = new UserSettings({ user }); + user.settings.detailDisclosureOrder = { + ...user.settings.detailDisclosureOrder, + tv: order, + }; + const saved = await repo.save(user); + return res + .status(200) + .json( + normalizeSeriesDisclosureOrder( + saved.settings?.detailDisclosureOrder?.tv + ) + ); + } + ); + } catch (e) { + return next({ + status: e instanceof UserMutationActorUnauthorizedError ? 403 : 500, + message: + e instanceof UserMutationActorUnauthorizedError + ? 'Access denied.' + : e.message, + }); + } + } +); + const updateMediaFilterPin = ( current: Partial> | undefined, scope: MediaFilterScope, @@ -379,6 +482,7 @@ const serializeScopedDetailDisclosurePins = ( const legacyPins: UserSettingsDetailDisclosureResponse = { details: false, ...(mediaType === 'movie' ? { collection: false } : {}), + ...(mediaType === 'tv' ? { mediaServer: false, overview: false } : {}), cast: mediaType === 'movie' && settings?.detailDisclosureCastPinned === true, crew: @@ -399,7 +503,9 @@ const serializeScopedDetailDisclosurePins = ( const parseDetailDisclosurePinsBody = ( body: unknown, includeCollection = false, - includeDetails = false + includeDetails = false, + includeMediaServer = false, + includeOverview = false ): { value: UserSettingsDetailDisclosureResponse } | { error: string } => { const parsedBody = parseUserSettingsBodyObject(body); @@ -413,10 +519,13 @@ const parseDetailDisclosurePinsBody = ( ...keys, ...(includeDetails ? (['details'] as const) : []), 'advancedOptions', + 'taskFilters', 'filters', 'mediaFilters', 'sortBy', ...(includeCollection ? (['collection'] as const) : []), + ...(includeMediaServer ? (['mediaServer'] as const) : []), + ...(includeOverview ? (['overview'] as const) : []), ]; for (const key of allowedKeys) { if (!hasOwn(parsedBody.value, key)) { @@ -1366,7 +1475,9 @@ userSettingsRoutes.post< const parsedBody = parseDetailDisclosurePinsBody( req.body, mediaType === 'movie', - true + true, + mediaType === 'tv', + mediaType === 'tv' ); if (!isDetailDisclosureMediaType(mediaType)) { diff --git a/server/test/index.mts b/server/test/index.mts index aa1128638..9d331232a 100644 --- a/server/test/index.mts +++ b/server/test/index.mts @@ -1,8 +1,9 @@ // Runs unit tests using the `node:test` runner. import { Command, Option } from 'commander'; -import { createWriteStream, mkdirSync } from 'node:fs'; +import { createWriteStream, mkdirSync, mkdtempSync, rmSync } from 'node:fs'; import { glob, readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { run } from 'node:test'; import * as reporters from 'node:test/reporters'; @@ -77,8 +78,15 @@ if (positionals.length > 0) { files.sort(); } -// @ts-ignore +// @ts-expect-error Node's test runner needs this explicit test environment. process.env.NODE_ENV = 'test'; +const testConfigDirectory = mkdtempSync( + join(tmpdir(), 'seerrng-node-test-config-') +); +process.env.CONFIG_DIRECTORY = testConfigDirectory; +process.on('exit', () => { + rmSync(testConfigDirectory, { force: true, recursive: true }); +}); // configure ts process.env.TS_NODE_PROJECT = resolveImport('../tsconfig.json'); process.env.TS_NODE_FILES = 'true'; diff --git a/server/test/networkGuard.test.mjs b/server/test/networkGuard.test.mjs new file mode 100644 index 000000000..89c2c0f73 --- /dev/null +++ b/server/test/networkGuard.test.mjs @@ -0,0 +1,147 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { describe, it } from 'node:test'; +import vm from 'node:vm'; +import ts from 'typescript'; + +const source = readFileSync(new URL('./setup.ts', import.meta.url), 'utf8'); + +// Execute the actual setup module with in-memory transports and hooks. No +// application initialization, real sockets or provider access is needed. +function guardFixture({ + env = {}, + file = '/app/unrelated.test.ts', + code = source, +} = {}) { + const after = []; + const forwarded = []; + const transports = Object.fromEntries( + ['http', 'https'].map((scheme) => [ + scheme, + Object.fromEntries( + ['request', 'get'].map((method) => [ + method, + (...args) => { + forwarded.push({ scheme, method, args }); + return 'loopback-response'; + }, + ]) + ), + ]) + ); + const dependencies = { + '@server/lib/settings': { getSettings: () => ({}) }, + '@server/logger': { silent: false }, + 'node:http': transports.http, + 'node:https': transports.https, + 'node:test': { + before: () => {}, + after: (callback) => after.push(callback), + }, + }; + const compiled = ts.transpileModule(code, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + esModuleInterop: true, + }, + }).outputText; + vm.runInNewContext(compiled, { + exports: {}, + require: (name) => { + if (!Object.hasOwn(dependencies, name)) + throw new Error(`Unexpected import: ${name}`); + return dependencies[name]; + }, + process: { + env: { SEERR_TEST_FAIL_ON_NETWORK: 'true', ...env }, + argv: ['node', file], + }, + URL, + console, + }); + assert.equal(after.length, 1); + return { transports, forwarded, finish: () => after[0]() }; +} + +describe('strict test network guard', () => { + it('blocks both formerly deferred hosts even with the retired opt-in flag', () => { + for (const [file, scheme, host] of [ + [ + '/app/server/lib/availabilitySync.test.ts', + 'https', + 'api.themoviedb.org', + ], + ['/app/server/lib/scanners/plex/index.test.ts', 'http', 'plex.local'], + ]) { + const fixture = guardFixture({ + file, + env: { SEERR_TEST_DEFER_KNOWN_NETWORK_GAPS: '2026-10-02' }, + }); + assert.throws( + () => fixture.transports[scheme].request(`${scheme}://${host}/fixture`), + /Blocked outbound request/ + ); + assert.equal(fixture.forwarded.length, 0); + assert.throws(fixture.finish, /Test reached the network/); + } + }); + + it('fails the after-hook when a test swallows an external request error', () => { + const fixture = guardFixture(); + try { + fixture.transports.https.get('https://another.invalid/fixture'); + } catch { + /* emulate a provider fallback */ + } + assert.equal(fixture.forwarded.length, 0); + assert.throws(fixture.finish, /https:\/\/another\.invalid/); + }); + + it('preserves loopback URL and options overloads for local HTTP tests', () => { + const fixture = guardFixture(); + assert.equal( + fixture.transports.http.request('http://127.0.0.1:1234/test'), + 'loopback-response' + ); + assert.equal( + fixture.transports.http.get({ host: 'localhost:1234' }), + 'loopback-response' + ); + assert.equal( + fixture.transports.https.request({ hostname: '[::1]' }), + 'loopback-response' + ); + assert.equal(fixture.forwarded.length, 3); + assert.doesNotThrow(fixture.finish); + }); + + it('honors an explicit external hostname overriding a loopback URL', () => { + const fixture = guardFixture(); + assert.throws( + () => + fixture.transports.http.request('http://localhost/fixture', { + hostname: 'plex.local', + }), + /Blocked outbound request/ + ); + assert.equal(fixture.forwarded.length, 0); + assert.throws(fixture.finish, /http:\/\/plex\.local/); + }); + + it('detects losing the swallowed-attempt recording contract', () => { + const mutated = source.replace( + 'blocked.add(target);', + '/* regression: attempt not recorded */' + ); + assert.notEqual(mutated, source); + const fixture = guardFixture({ code: mutated }); + assert.throws( + () => fixture.transports.http.get('http://plex.local/fixture'), + /Blocked outbound request/ + ); + // Demonstrate why blocking alone is insufficient: this mutant loses the + // required final failure, unlike the intact-module cases above. + assert.doesNotThrow(fixture.finish); + }); +}); diff --git a/server/utils/detailDisclosureOrder.ts b/server/utils/detailDisclosureOrder.ts new file mode 100644 index 000000000..f65378252 --- /dev/null +++ b/server/utils/detailDisclosureOrder.ts @@ -0,0 +1,59 @@ +export const seriesDisclosureRoles = [ + 'cast', + 'crew', + 'subjectTags', + 'details', + 'mediaServer', + 'overview', +] as const; +export type SeriesDisclosureRole = (typeof seriesDisclosureRoles)[number]; +export type DetailDisclosureOrder = Partial< + Record<'tv', SeriesDisclosureRole[]> +>; + +export const normalizeSeriesDisclosureOrder = ( + value: unknown +): SeriesDisclosureRole[] => { + const known = new Set(seriesDisclosureRoles); + const supplied = Array.isArray(value) + ? value.filter( + (role): role is SeriesDisclosureRole => + typeof role === 'string' && known.has(role) + ) + : []; + // A newly introduced Overview starts left; once explicitly ordered, retain + // the user's placement along with every existing role's relative order. + if (!supplied.includes('overview')) supplied.unshift('overview'); + return [...new Set([...supplied, ...seriesDisclosureRoles])]; +}; + +export const parseSeriesDisclosureOrder = ( + value: unknown +): SeriesDisclosureRole[] | null => { + if ( + !Array.isArray(value) || + value.length > seriesDisclosureRoles.length || + value.some( + (role) => + typeof role !== 'string' || + !seriesDisclosureRoles.includes(role as SeriesDisclosureRole) + ) || + new Set(value).size !== value.length + ) + return null; + return normalizeSeriesDisclosureOrder(value); +}; + +export const insertDisclosureRole = ( + order: readonly SeriesDisclosureRole[], + role: SeriesDisclosureRole, + target: SeriesDisclosureRole, + after: boolean +): SeriesDisclosureRole[] => { + if (role === target) return [...order]; + const next = order.filter((item) => item !== role); + const index = next.indexOf(target); + if (index < 0) return [...order]; + next.splice(index + Number(after), 0, role); + return next; +}; diff --git a/src/components/ArtistCard/index.tsx b/src/components/ArtistCard/index.tsx index b9a9a8fc1..d285adae9 100644 --- a/src/components/ArtistCard/index.tsx +++ b/src/components/ArtistCard/index.tsx @@ -18,7 +18,6 @@ const ArtistCard = ({ name, artistThumb, subName, - canExpand = false, }: ArtistCardProps) => { const [isHovered, setHovered] = useState(false); @@ -26,7 +25,8 @@ const ArtistCard = ({ setHovered(true)} onMouseLeave={() => setHovered(false)} onKeyDown={(e) => { @@ -38,16 +38,13 @@ const ArtistCard = ({ tabIndex={0} >
-
-
+
+
@@ -70,7 +67,12 @@ const ArtistCard = ({ )}
-
{name}
+
+ {name} +
{subName && (
{ return (
-
+
{intl.formatMessage(messages[albumTypeMessages[type]])} ({totalCount}) diff --git a/src/components/Association/AssociationBadge.tsx b/src/components/Association/AssociationBadge.tsx index bc451f758..bbe8876a4 100644 --- a/src/components/Association/AssociationBadge.tsx +++ b/src/components/Association/AssociationBadge.tsx @@ -15,6 +15,7 @@ import AssociationPopover from './AssociationPopover'; const messages = defineMessages('components.Association', { associations: 'Associations', + associationsDescription: 'View media related or similar to this title.', browseMore: 'Browse More...', }); @@ -72,7 +73,7 @@ const AssociationBadge = ({ const associationLabel = intl.formatMessage(messages.associations); const buttonClass = variant === 'card' - ? 'poster-control poster-control-association app-control-shadow-exempt shadow-md shadow-cyan-950/40' + ? 'poster-control poster-control-association app-control-shadow-exempt' : 'flex h-8 w-8 items-center justify-center rounded-full bg-gray-800/35 text-gray-300 ring-1 ring-gray-700 transition hover:bg-gray-700/55 hover:text-white active:bg-gray-700/70'; const toggleAssociations = (event: React.MouseEvent) => { @@ -86,7 +87,7 @@ const AssociationBadge = ({ return ( <> - + {variant === 'button' ? ( ) : ( @@ -109,7 +110,7 @@ const AssociationBadge = ({ onClick={toggleAssociations} >
- - -
- {( - [ - ['all', messages.allMedia], - ['movie', messages.movies], - ['tv', messages.series], - ['music', messages.music], - ['book', messages.books], - ['comic', messages.comics], - ['magazine', messages.magazines], - ] as const - ).map(([value, label]) => ( - - - - ))} -
-
- -
-
- {intl.formatMessage(messages.filters)} -
-
- { - setTimeFrame(value as TimeFrame); - resetPage(); - }} - /> - -
-
+ + +
-
-
- {intl.formatMessage(messages.sortBy)} -
-
- {( - [ - ['date', messages.sortDate], - ['title', messages.sortTitle], - ['mediaType', messages.sortMediaType], - ] as const - ).map(([value, label]) => { - const active = sort === value; - const DirectionIcon = - active && sortDirection === 'asc' - ? BarsArrowUpIcon - : BarsArrowDownIcon; - return ( - - ); - })} -
-
+ + + {filterOptions.map((option) => ( + + ))} +
+ ), + }, + { + section: 'mediaFilters', + label: intl.formatMessage(messages.mediaFilters), + children: ( +
+ {( + [ + ['all', messages.allMedia], + ['movie', messages.movies], + ['tv', messages.series], + ['music', messages.music], + ['book', messages.books], + ['comic', messages.comics], + ['magazine', messages.magazines], + ] as const + ).map(([value, label]) => ( + + + + ))} +
+ ), + }, + { + section: 'filters', + label: intl.formatMessage(messages.filters), + children: ( +
+ { + setTimeFrame(value as TimeFrame); + resetPage(); + }} + /> + +
+ ), + }, + { + section: 'sortBy', + label: intl.formatMessage(messages.sortBy), + children: ( +
+ {( + [ + ['date', messages.sortDate], + ['title', messages.sortTitle], + ['mediaType', messages.sortMediaType], + ] as const + ).map(([value, label]) => { + const active = sort === value; + const DirectionIcon = + active && sortDirection === 'asc' + ? BarsArrowUpIcon + : BarsArrowDownIcon; + return ( + + ); + })} +
+ ), + }, + ]} + /> {!data ? ( diff --git a/src/components/BlocklistConfirmationModal/index.tsx b/src/components/BlocklistConfirmationModal/index.tsx index 5de3a4c17..e18baa221 100644 --- a/src/components/BlocklistConfirmationModal/index.tsx +++ b/src/components/BlocklistConfirmationModal/index.tsx @@ -29,12 +29,7 @@ const BlocklistConfirmationModal = ({ return ( { />
-

+

{intl.formatMessage(messages.collection)} ({visibleBooks.length}) diff --git a/src/components/CollectionDetails/CollectionAssociationsButton.tsx b/src/components/CollectionDetails/CollectionAssociationsButton.tsx index 4a3401af9..640ae9793 100644 --- a/src/components/CollectionDetails/CollectionAssociationsButton.tsx +++ b/src/components/CollectionDetails/CollectionAssociationsButton.tsx @@ -55,7 +55,7 @@ const CollectionAssociationsButton = ({ buttonSize="sm" onClick={() => void open()} > - + {intl.formatMessage(messages.associations)} diff --git a/src/components/CollectionDetails/CollectionMetadataDisclosures.tsx b/src/components/CollectionDetails/CollectionMetadataDisclosures.tsx index 86c744587..746bfdbc0 100644 --- a/src/components/CollectionDetails/CollectionMetadataDisclosures.tsx +++ b/src/components/CollectionDetails/CollectionMetadataDisclosures.tsx @@ -27,8 +27,8 @@ const messages = defineMessages('components.CollectionDetails.Metadata', { subjectTags: 'Subject Tags', fullCastList: 'Full Cast List', fullCrewList: 'Full Crew List', - noCast: 'No cast information available', - noCrew: 'No crew information available', + noCast: 'No Cast Information Available', + noCrew: 'No Crew Information Available', noTags: 'No subject tags available', }); diff --git a/src/components/CollectionDetails/CollectionPlayOnDeviceButton.tsx b/src/components/CollectionDetails/CollectionPlayOnDeviceButton.tsx index 98d913dca..ed58149d3 100644 --- a/src/components/CollectionDetails/CollectionPlayOnDeviceButton.tsx +++ b/src/components/CollectionDetails/CollectionPlayOnDeviceButton.tsx @@ -99,7 +99,7 @@ const CollectionPlayOnDeviceButton = ({ void startPlayback(device); }} > - + {device.name} diff --git a/src/components/CollectionDetails/CollectionRatings.tsx b/src/components/CollectionDetails/CollectionRatings.tsx index a4f9d5cca..b40267d95 100644 --- a/src/components/CollectionDetails/CollectionRatings.tsx +++ b/src/components/CollectionDetails/CollectionRatings.tsx @@ -28,18 +28,27 @@ const CollectionRatings = ({ ratings, total, loading = false, + unknownValue = '—', + loadingValue = '…', + reserveValueSpace = false, + tooltips, }: { ratings: CollectionRating[]; total?: number; loading?: boolean; + unknownValue?: string; + loadingValue?: string; + reserveValueSpace?: boolean; + tooltips?: Partial>; }) => { const intl = useIntl(); return ( <> {ratings.map((rating) => { const source = intl.formatMessage(messages[rating.source]); - const value = loading ? undefined : rating.value; - const tooltip = loading + const value = loading && !reserveValueSpace ? undefined : rating.value; + const pending = loading && value === undefined; + const tooltip = pending ? intl.formatMessage(messages.loading, { source }) : total !== undefined ? intl.formatMessage(messages.average, { @@ -49,7 +58,7 @@ const CollectionRatings = ({ }) : value === undefined ? intl.formatMessage(messages.unavailable, { source }) - : source; + : (tooltips?.[rating.source] ?? source); const Icon = rating.source === 'critics' ? value !== undefined && value < 60 @@ -88,10 +97,10 @@ const CollectionRatings = ({ /> ) : null} - {loading - ? '…' + {pending + ? loadingValue : value === undefined - ? '—' + ? unknownValue : rating.source === 'imdb' || rating.source === 'trakt' ? value.toFixed(1) : `${Math.round(value)}%`} @@ -107,6 +116,7 @@ const CollectionRatings = ({ {href ? ( ) : ( - + {content} )} diff --git a/src/components/ComicDetails/index.tsx b/src/components/ComicDetails/index.tsx index e9c7752d1..d40fe1aff 100644 --- a/src/components/ComicDetails/index.tsx +++ b/src/components/ComicDetails/index.tsx @@ -536,7 +536,7 @@ const ComicDetails = () => { rel="noreferrer" className="app-button-default inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium" > - + {intl.formatMessage(messages.viewOnComicVine)} )} diff --git a/src/components/Common/BookFormatBadge/index.tsx b/src/components/Common/BookFormatBadge/index.tsx index 46b3b5e19..c3d5834b3 100644 --- a/src/components/Common/BookFormatBadge/index.tsx +++ b/src/components/Common/BookFormatBadge/index.tsx @@ -1,7 +1,6 @@ import globalMessages from '@app/i18n/globalMessages'; import { BookOpenIcon, SpeakerWaveIcon } from '@heroicons/react/24/outline'; import { useIntl } from 'react-intl'; -import { twMerge } from 'tailwind-merge'; export type BookFormat = 'book' | 'ebook' | 'audiobook' | 'both'; export type RequestedBookFormat = Exclude; @@ -54,30 +53,28 @@ const BookFormatBadge = ({ const Icon = isAudio ? SpeakerWaveIcon : BookOpenIcon; const variantClasses = { - card: 'poster-control poster-control-book-format max-w-full shadow-md', - compact: - 'inline-flex max-w-full items-center gap-1 rounded-full border border-amber-500/70 bg-amber-700/35 px-2 py-1 text-[11px] font-semibold leading-none text-amber-50', - inline: - 'inline-flex max-w-full items-center gap-1 text-sm font-medium text-amber-100', - selector: - 'inline-flex min-w-0 items-center gap-2 text-left text-sm font-semibold text-gray-100', + card: 'poster-control poster-control-book-format media-type-badge-width', + compact: 'app-filter-button compact-select-warning', + inline: 'app-filter-button compact-select-warning', + selector: 'discover-filter-control compact-select-warning', } as const; + const badgeClassName = [variantClasses[variant], className] + .filter(Boolean) + .join(' '); + return ( - + {showIcon && (isBoth ? ( - ); }; diff --git a/src/components/Common/Button/index.tsx b/src/components/Common/Button/index.tsx index f2cd8c636..12743634b 100644 --- a/src/components/Common/Button/index.tsx +++ b/src/components/Common/Button/index.tsx @@ -1,4 +1,5 @@ import { + ArrowPathIcon, MagnifyingGlassIcon, TrashIcon, XMarkIcon, @@ -42,7 +43,7 @@ type BaseProps

= { buttonSize?: 'standard' | 'default' | 'lg' | 'md' | 'sm'; /** Explains a state-based disabled action in the shared styled tooltip. */ disabledReason?: string; - buttonIcon?: 'cancel' | 'browse' | 'delete'; + buttonIcon?: 'cancel' | 'browse' | 'delete' | 'retry'; /** Uses shared square geometry for an action with only an icon. */ iconOnly?: boolean; // Had to do declare this manually as typescript would assume e was of type any otherwise @@ -112,13 +113,15 @@ function Button

( )} + data-button-icon={buttonIcon} data-button-help={props.title} title={undefined} ref={ref as ForwardedRef} > - + {buttonIcon === 'cancel' &&

( diff --git a/src/components/Common/Dropdown/index.tsx b/src/components/Common/Dropdown/index.tsx index 91cfb8c11..270478f2f 100644 --- a/src/components/Common/Dropdown/index.tsx +++ b/src/components/Common/Dropdown/index.tsx @@ -43,10 +43,7 @@ const DropdownItems = ({ return (

@@ -83,7 +80,7 @@ const Dropdown = ({ }; return ( - + - {text} + {text} {children && (dropdownIcon ? dropdownIcon : )} {children && {children}} diff --git a/src/components/Common/FormatRequestControl/index.tsx b/src/components/Common/FormatRequestControl/index.tsx index 5095ac42e..039b03619 100644 --- a/src/components/Common/FormatRequestControl/index.tsx +++ b/src/components/Common/FormatRequestControl/index.tsx @@ -49,10 +49,10 @@ const FormatRequestControl = ({ className={`format-request-control format-request-control-single ${className ?? ''}`} > -

+ } + isActive={inView} + notice={ + error && + failure && ( +
- - - ) : ( - - )} -
- )} - {inView && ( - ( - - ))} - isLoading={isLoading} - /> - )} + + + + {failure.kind === 'reconnect' ? ( + + + + ) : ( + + )} +
+ ) + } + sliderKey={row.id} + items={data?.items.map((item) => ( + + ))} + isLoading={isLoading} + />
); } diff --git a/src/components/Discover/PinnedFilterInteraction.test.tsx b/src/components/Discover/PinnedFilterInteraction.test.tsx new file mode 100755 index 000000000..2ddba9114 --- /dev/null +++ b/src/components/Discover/PinnedFilterInteraction.test.tsx @@ -0,0 +1,110 @@ +import { JSDOM } from 'jsdom'; +import React, { act, StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { IntlProvider } from 'react-intl'; +import { SWRConfig } from 'swr'; +import { afterEach, expect, it, vi } from 'vitest'; +import { PinnedFilterSectionGroup } from './PinnedFilterSection'; + +const state = vi.hoisted(() => ({ + user: { + id: 7, + settings: { + detailDisclosurePins: { tv: { filters: false, sortBy: false } }, + }, + }, + revalidate: vi.fn().mockResolvedValue(undefined), + post: vi.fn(), +})); +vi.mock('@app/hooks/useUser', () => ({ useUser: () => state })); +vi.mock('axios', () => ({ default: { post: state.post } })); +afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +it('Series filter pin buttons drive the real saved-state hook and disclosure component in both directions', async () => { + const dom = new JSDOM('
'); + vi.stubGlobal('window', dom.window); + vi.stubGlobal('document', dom.window.document); + vi.stubGlobal('React', React); + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + state.user.settings.detailDisclosurePins.tv = { + filters: false, + sortBy: false, + }; + let saved = { ...state.user.settings.detailDisclosurePins.tv }; + state.post.mockImplementation( + async (endpoint: string, patch: Record) => { + expect(endpoint).toBe('/api/v1/user/7/settings/detail-disclosures/tv'); + saved = { ...saved, ...patch }; + return { data: { ...saved } }; + } + ); + const root = createRoot(document.getElementById('root')!); + const cache = new Map(); + const render = () => + root.render( + + cache, + revalidateOnMount: false, + revalidateOnFocus: false, + }} + > + + Filter panel, + }, + { + section: 'sortBy', + label: 'Sort By', + children: Sort panel, + }, + ]} + /> + + + + ); + const pin = (section: string) => + document.querySelector( + `section[aria-label="${section}"] button[aria-pressed]` + ) as HTMLButtonElement; + const heading = (section: string) => + document.querySelector( + `section[aria-label="${section}"] button[aria-expanded]` + ) as HTMLButtonElement; + try { + await act(async () => render()); + for (const section of ['Filters', 'Sort By']) { + expect(pin(section).getAttribute('aria-pressed')).toBe('false'); + await act(async () => pin(section).click()); + expect(pin(section).getAttribute('aria-pressed')).toBe('true'); + expect(heading(section).getAttribute('aria-expanded')).toBe('true'); + // Remount simulates a fresh page visit against the saved mocked cache. + await act(async () => root.render(null)); + await act(async () => render()); + expect(heading(section).getAttribute('aria-expanded')).toBe('true'); + await act(async () => pin(section).click()); + expect(pin(section).getAttribute('aria-pressed')).toBe('false'); + expect(heading(section).getAttribute('aria-expanded')).toBe('false'); + } + expect(state.post.mock.calls.map((call) => call[1])).toEqual([ + { filters: true }, + { filters: false }, + { sortBy: true }, + { sortBy: false }, + ]); + expect(state.revalidate).toHaveBeenCalledTimes(4); + } finally { + await act(async () => root.unmount()); + dom.window.close(); + } +}); diff --git a/src/components/Discover/PinnedFilterSection.test.tsx b/src/components/Discover/PinnedFilterSection.test.tsx new file mode 100755 index 000000000..e613651d8 --- /dev/null +++ b/src/components/Discover/PinnedFilterSection.test.tsx @@ -0,0 +1,176 @@ +import { JSDOM } from 'jsdom'; +import type * as ReactModule from 'react'; +import React, { act, StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, expect, it, vi } from 'vitest'; +import { PinnedFilterSectionGroup } from './PinnedFilterSection'; + +const state = vi.hoisted(() => ({ + pins: { + taskFilters: false, + mediaFilters: false, + filters: false, + sortBy: false, + }, + togglePinned: vi.fn(), +})); +// React may defer a functional updater. Exercise that supported scheduling +// explicitly: correctness must not depend on an eager synchronous callback. +vi.mock('react', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useState: (initial: T | (() => T)) => { + const [value, update] = actual.useState(initial); + const deferredUpdate = (next: React.SetStateAction) => { + queueMicrotask(() => update(next)); + }; + return [value, deferredUpdate] as const; + }, + }; +}); +vi.mock('@app/hooks/useDetailDisclosurePins', () => ({ default: () => state })); +vi.mock('@app/components/MediaDetails/DetailDisclosureButton', () => ({ + default: ({ + label, + open, + pinned, + onClick, + onPinClick, + controls, + }: { + label: string; + open: boolean; + pinned: boolean; + onClick: () => void; + onPinClick: () => void; + controls: string; + }) => ( + <> + + + + ), +})); +afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +async function fixture( + run: (render: () => Promise, doc: Document) => Promise +) { + const dom = new JSDOM('
'); + vi.stubGlobal('window', dom.window); + vi.stubGlobal('document', dom.window.document); + vi.stubGlobal('React', React); + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + state.pins = { + taskFilters: false, + mediaFilters: false, + filters: false, + sortBy: false, + }; + const root = createRoot(document.getElementById('root')!); + const render = async () => { + await act(async () => + root.render( + + Filter controls, + }, + { + section: 'sortBy', + label: 'Sort', + children: Sort controls, + }, + ]} + /> + + ) + ); + }; + try { + await render(); + await run(render, document); + } finally { + await act(async () => root.unmount()); + dom.window.close(); + } +} + +it('late saved pins expand and unpins collapse the matching section in StrictMode', async () => { + await fixture(async (render, doc) => { + const heading = () => doc.querySelector('button[aria-controls]')!; + expect(heading().getAttribute('aria-expanded')).toBe('false'); + state.pins = { ...state.pins, filters: true }; + await render(); + expect(heading().getAttribute('aria-expanded')).toBe('true'); + expect(doc.body.textContent).toContain('Filter controls'); + state.pins = { ...state.pins, filters: false }; + await render(); + expect(heading().getAttribute('aria-expanded')).toBe('false'); + expect(doc.body.textContent).not.toContain('Filter controls'); + }); +}); + +it('a pin change leaves other sections and manual heading toggles independent', async () => { + await fixture(async (render, doc) => { + const headings = () => + Array.from( + doc.querySelectorAll('button[aria-controls]') + ); + await act(async () => headings()[1].click()); + expect(headings()[1].getAttribute('aria-expanded')).toBe('true'); + state.pins = { ...state.pins, filters: true }; + await render(); + expect(headings()[0].getAttribute('aria-expanded')).toBe('true'); + expect(headings()[1].getAttribute('aria-expanded')).toBe('true'); + await act(async () => headings()[0].click()); + expect(headings()[0].getAttribute('aria-expanded')).toBe('false'); + await act(async () => + ( + doc.querySelector( + 'button[aria-label="Pin Filters"]' + ) as HTMLButtonElement + ).click() + ); + expect(state.togglePinned).toHaveBeenCalledExactlyOnceWith('filters'); + }); +}); + +it('restored saved pin state reverses optimistic expansion without a provider or account write', async () => { + await fixture(async (render, doc) => { + state.pins = { ...state.pins, filters: true, sortBy: true }; + await render(); + expect(doc.querySelectorAll('button[aria-expanded="true"]')).toHaveLength( + 2 + ); + state.pins = { ...state.pins, filters: false }; + await render(); + expect(doc.querySelectorAll('button[aria-expanded="true"]')).toHaveLength( + 1 + ); + expect(doc.body.textContent).toContain('Sort controls'); + expect(doc.body.textContent).not.toContain('Filter controls'); + }); +}); diff --git a/src/components/Discover/PinnedFilterSection.tsx b/src/components/Discover/PinnedFilterSection.tsx index 79d153987..67e1ba24b 100644 --- a/src/components/Discover/PinnedFilterSection.tsx +++ b/src/components/Discover/PinnedFilterSection.tsx @@ -1,7 +1,101 @@ import DetailDisclosureButton from '@app/components/MediaDetails/DetailDisclosureButton'; import useDetailDisclosurePins from '@app/hooks/useDetailDisclosurePins'; -import type { DetailDisclosureMediaType } from '@server/interfaces/api/userSettingsInterfaces'; -import { useEffect, useState, type ReactNode } from 'react'; +import type { + DetailDisclosureMediaType, + DetailDisclosurePin, +} from '@server/interfaces/api/userSettingsInterfaces'; +import { useEffect, useId, useRef, useState, type ReactNode } from 'react'; + +export type PinnedFilterSectionName = Extract< + DetailDisclosurePin, + 'taskFilters' | 'filters' | 'mediaFilters' | 'sortBy' +>; + +const filterSectionNames: PinnedFilterSectionName[] = [ + 'taskFilters', + 'mediaFilters', + 'filters', + 'sortBy', +]; + +export function PinnedFilterSectionGroup({ + mediaType, + sections, +}: { + mediaType: DetailDisclosureMediaType; + sections: { + section: PinnedFilterSectionName; + label: string; + children: ReactNode; + }[]; +}) { + const { pins, togglePinned } = useDetailDisclosurePins(mediaType); + const groupId = useId(); + const [openSections, setOpenSections] = useState< + Record + >(() => ({ + taskFilters: Boolean(pins.taskFilters), + mediaFilters: Boolean(pins.mediaFilters), + filters: Boolean(pins.filters), + sortBy: Boolean(pins.sortBy), + })); + const previousPins = useRef(pins); + + useEffect(() => { + // Capture transitions before queueing an updater: React can run the + // updater after this effect has advanced the previous-pin snapshot. + const changes = filterSectionNames + .filter((section) => previousPins.current[section] !== pins[section]) + .map((section) => ({ section, open: Boolean(pins[section]) })); + previousPins.current = pins; + + if (changes.length) { + setOpenSections((current) => { + const next = { ...current }; + for (const { section, open } of changes) { + next[section] = open; + } + return next; + }); + } + }, [pins]); + + return ( +
+ {sections.map(({ section, label, children }) => { + const controls = `${groupId}-${section}`; + return ( +
+
+ + setOpenSections((current) => ({ + ...current, + [section]: !current[section], + })) + } + pinned={pins[section]} + onPinClick={() => void togglePinned(section)} + controls={controls} + /> +
+ {openSections[section] ? ( +
+ {children} +
+ ) : null} +
+ ); + })} +
+ ); +} export default function PinnedFilterSection({ mediaType, @@ -19,7 +113,7 @@ export default function PinnedFilterSection({ const sectionPinned = Boolean(pins[section]); useEffect(() => setOpen(sectionPinned), [sectionPinned]); return ( -
+
void togglePinned(section)} />
- {open &&
{children}
} + {open &&
{children}
}
); } diff --git a/src/components/Discover/PlexWatchlistSlider/index.tsx b/src/components/Discover/PlexWatchlistSlider/index.tsx index bca00a171..6518ebe87 100644 --- a/src/components/Discover/PlexWatchlistSlider/index.tsx +++ b/src/components/Discover/PlexWatchlistSlider/index.tsx @@ -111,12 +111,14 @@ const PlexWatchlistSlider = () => { return (
-
- - {intl.formatMessage(messages.plexwatchlist)} - -
+ + {intl.formatMessage(messages.plexwatchlist)} + + + } sliderKey="watchlist" isLoading={isLoading} isEmpty={isWatchlistEmpty} diff --git a/src/components/Discover/RecentRequestsSlider/index.tsx b/src/components/Discover/RecentRequestsSlider/index.tsx index 3ccb1d9ef..1d12fed85 100644 --- a/src/components/Discover/RecentRequestsSlider/index.tsx +++ b/src/components/Discover/RecentRequestsSlider/index.tsx @@ -44,31 +44,31 @@ const RecentRequestsSlider = () => { return (
-
- - {intl.formatMessage(sliderTitles.recentrequests)} - -
- - {hasServiceErrors && - (hasPermission(Permission.MANAGE_REQUESTS) || - hasPermission(Permission.REQUEST_ADVANCED)) && ( -
- - - {intl.formatMessage(messages.unableToConnect, { - services: [ - ...requests.serviceErrors.radarr.map((s) => s.name), - ...requests.serviceErrors.sonarr.map((s) => s.name), - ...requests.serviceErrors.lidarr.map((s) => s.name), - ...requests.serviceErrors.readarr.map((s) => s.name), - ].join(', '), - })} - -
- )} - + {intl.formatMessage(sliderTitles.recentrequests)} + + } + notice={ + hasServiceErrors && + (hasPermission(Permission.MANAGE_REQUESTS) || + hasPermission(Permission.REQUEST_ADVANCED)) && ( +
+ + + {intl.formatMessage(messages.unableToConnect, { + services: [ + ...requests.serviceErrors.radarr.map((s) => s.name), + ...requests.serviceErrors.sonarr.map((s) => s.name), + ...requests.serviceErrors.lidarr.map((s) => s.name), + ...requests.serviceErrors.readarr.map((s) => s.name), + ].join(', '), + })} + +
+ ) + } compact sliderKey="requests" disableItemContentVisibility diff --git a/src/components/Discover/RecentlyAddedSlider/index.tsx b/src/components/Discover/RecentlyAddedSlider/index.tsx index 862e56e5d..d183a1f79 100644 --- a/src/components/Discover/RecentlyAddedSlider/index.tsx +++ b/src/components/Discover/RecentlyAddedSlider/index.tsx @@ -82,12 +82,14 @@ const RecentlyAddedSlider = () => { return (
-
-
- {intl.formatMessage(messages.recentlyAdded)} -
-
+
+ {intl.formatMessage(messages.recentlyAdded)} +
+ + } sliderKey="media" isLoading={isLoading} isEmpty={!!media && !recentlyAddedCards.length && !mediaError} diff --git a/src/components/Discover/StudioSlider/index.tsx b/src/components/Discover/StudioSlider/index.tsx index a9d385aae..cfd6105f3 100644 --- a/src/components/Discover/StudioSlider/index.tsx +++ b/src/components/Discover/StudioSlider/index.tsx @@ -91,12 +91,14 @@ const StudioSlider = () => { const intl = useIntl(); return (
-
-
- {intl.formatMessage(messages.studios)} -
-
+
+ {intl.formatMessage(messages.studios)} +
+ + } compact sliderKey="studios" isLoading={false} diff --git a/src/components/Discover/TvGenreList/index.tsx b/src/components/Discover/TvGenreList/index.tsx index 17fffec35..8a93c7ffc 100644 --- a/src/components/Discover/TvGenreList/index.tsx +++ b/src/components/Discover/TvGenreList/index.tsx @@ -19,18 +19,32 @@ const TvGenreList = () => { `/api/v1/discover/genreslider/tv` ); + const title = intl.formatMessage(messages.seriesgenres); + if (!data && !error) { - return ; + return ( + <> + +
{title}
+ + + ); } if (!data) { - return ; + return ( + <> + +
{title}
+ + + ); } return ( <> -
+
{intl.formatMessage(messages.seriesgenres)}
    diff --git a/src/components/Discover/TvGenreSlider/index.tsx b/src/components/Discover/TvGenreSlider/index.tsx index 679f3300e..46fad4459 100644 --- a/src/components/Discover/TvGenreSlider/index.tsx +++ b/src/components/Discover/TvGenreSlider/index.tsx @@ -29,12 +29,14 @@ const TvGenreSlider = () => { return (
    -
    - - {intl.formatMessage(messages.tvgenres)} - -
    + + {intl.formatMessage(messages.tvgenres)} + + + } compact sliderKey="tv-genres" isLoading={isLoading && !error} diff --git a/src/components/Discover/Upcoming.tsx b/src/components/Discover/Upcoming.tsx index deb1c2408..571a6abfc 100644 --- a/src/components/Discover/Upcoming.tsx +++ b/src/components/Discover/Upcoming.tsx @@ -24,14 +24,22 @@ const UpcomingMovies = () => { error, } = useDiscover('/api/v1/discover/movies/upcoming'); + const title = intl.formatMessage(messages.upcomingmovies); + if (error) { - return ; + return ( + <> + +
    {title}
    + + + ); } return ( <> -
    +
    {intl.formatMessage(messages.upcomingmovies)}
    { .split('T')[0]; if (!discoverData && !discoverError) { - return ; + return ( + <> + +
    {intl.formatMessage(messages.discover)}
    + + + ); } return ( -
    +
    +
    {intl.formatMessage(messages.discover)}
    { aria-label={intl.formatMessage(messages.customizediscover)} className="shadow" > - + - + {intl.formatMessage(messages.edit)} )} @@ -141,7 +136,7 @@ const IssueComment = ({ buttonSize="sm" title={intl.formatMessage(messages.deleteHelp)} > - + {intl.formatMessage(messages.delete)} )} @@ -194,7 +189,7 @@ const IssueComment = ({ buttonSize="sm" title={intl.formatMessage(messages.cancelHelp)} > - + {intl.formatMessage(globalMessages.cancel)}
    diff --git a/src/components/IssueList/index.tsx b/src/components/IssueList/index.tsx index 863731596..930f25e6f 100644 --- a/src/components/IssueList/index.tsx +++ b/src/components/IssueList/index.tsx @@ -1,5 +1,7 @@ import Button from '@app/components/Common/Button'; -import LoadingSpinner from '@app/components/Common/LoadingSpinner'; +import LoadingSpinner, { + PageStatus, +} from '@app/components/Common/LoadingSpinner'; import PageTitle from '@app/components/Common/PageTitle'; import PaginationFooter from '@app/components/Common/PaginationFooter'; import { @@ -11,7 +13,7 @@ import { import { BOOK_GENRES } from '@app/components/Discover/FilterPanel/libraryFilterUtils'; import MediaFilterOption from '@app/components/Discover/MediaFilterOption'; import { tvNetworks } from '@app/components/Discover/NetworkSlider'; -import PinnedFilterSection from '@app/components/Discover/PinnedFilterSection'; +import { PinnedFilterSectionGroup } from '@app/components/Discover/PinnedFilterSection'; import { studios } from '@app/components/Discover/StudioSlider'; import FocusedIssue from '@app/components/IssueList/FocusedIssue'; import IssueItem from '@app/components/IssueList/IssueItem'; @@ -157,8 +159,29 @@ const IssueList = () => { : null ); - if (!data && !error) return ; - if (!data) return ; + const pendingHeading = ( + <> + +
    +

    {intl.formatMessage(messages.issues)}

    +
    + + ); + + if (!data && !error) + return ( + <> + {pendingHeading} + + + ); + if (!data) + return ( + <> + {pendingHeading} + + + ); const resetPage = () => page !== 1 && updateQueryParams('page', '1'); const clearMediaSpecificFilters = () => { @@ -242,274 +265,288 @@ const IssueList = () => { { label: intl.formatMessage(messages.sixMonths), value: '6m' }, ]; + const pinMediaType = + mediaFilter === 'tv' + ? 'tv' + : mediaFilter === 'music' + ? 'music' + : mediaFilter === 'book' + ? 'book' + : 'movie'; return ( <> -

    - - {intl.formatMessage(messages.issues)} - -

    +
    +

    {intl.formatMessage(messages.issues)}

    + +
    {focusedIssueId && ( )} -
    -
    - {intl.formatMessage(messages.taskFilters)} -
    -
    - - {( - [ - ['all', messages.allIssues, data.counts?.all ?? 0], - ['open', globalMessages.open, data.counts?.open ?? 0], - ['resolved', globalMessages.resolved, data.counts?.resolved ?? 0], - ] as const - ).map(([value, label, count]) => ( - - ))} - { - setIssueTypeFilter(value as IssueTypeFilter); - resetPage(); - }} - /> -
    -
    - -
    - {( - [ - ['all', messages.allMedia], - ['movie', messages.movies], - ['tv', messages.series], - ['music', messages.music], - ['book', messages.books], - ['comic', messages.comics], - ['magazine', messages.magazines], - ] as const - ).map(([value, label]) => ( - - - - ))} -
    -
    -
    -
    - {intl.formatMessage(messages.filters)} -
    -
    - { - setTimeFrame(value as TimeFrame); - resetPage(); - }} - /> - - {mediaFilter !== 'all' && - mediaFilter !== 'comic' && - mediaFilter !== 'magazine' && ( - <> + + + {( + [ + ['all', messages.allIssues, data.counts?.all ?? 0], + ['open', globalMessages.open, data.counts?.open ?? 0], + [ + 'resolved', + globalMessages.resolved, + data.counts?.resolved ?? 0, + ], + ] as const + ).map(([value, label, count]) => ( + + ))} { - setReleaseYearFilter(value); + setIssueTypeFilter(value as IssueTypeFilter); resetPage(); }} /> +
    + ), + }, + { + section: 'mediaFilters', + label: intl.formatMessage(messages.mediaFilters), + children: ( +
    + {( + [ + ['all', messages.allMedia], + ['movie', messages.movies], + ['tv', messages.series], + ['music', messages.music], + ['book', messages.books], + ['comic', messages.comics], + ['magazine', messages.magazines], + ] as const + ).map(([value, label]) => ( + + + + ))} +
    + ), + }, + { + section: 'filters', + label: intl.formatMessage(messages.filters), + children: ( +
    { - setGenreFilter(value); + setTimeFrame(value as TimeFrame); resetPage(); }} /> - - )} - {mediaFilter === 'movie' && ( - ({ - label: studio.name, - value: studio.name.toLocaleLowerCase(), - })), - ]} - onChange={(value) => { - setStudioFilter(value); - resetPage(); - }} - /> - )} - {mediaFilter === 'tv' && ( - ({ - label: network.name, - value: network.name.toLocaleLowerCase(), - })), - ]} - onChange={(value) => { - setNetworkFilter(value); - resetPage(); - }} - /> - )} - {mediaFilter === 'music' && ( - { - setAlbumTypeFilter(value); - resetPage(); - }} - /> - )} -
    -
    -
    -
    - {intl.formatMessage(messages.sortBy)} -
    -
    - {( - [ - ['added', messages.sortDate], - ['modified', messages.sortModified], - ['status', messages.sortStatus], - ] as const - ).map(([value, label]) => { - const active = sort === value; - const Icon = - active && direction === 'asc' - ? BarsArrowUpIcon - : BarsArrowDownIcon; - return ( - - ); - })} -
    -
    + + {mediaFilter !== 'all' && + mediaFilter !== 'comic' && + mediaFilter !== 'magazine' && ( + <> + { + setReleaseYearFilter(value); + resetPage(); + }} + /> + { + setGenreFilter(value); + resetPage(); + }} + /> + + )} + {mediaFilter === 'movie' && ( + ({ + label: studio.name, + value: studio.name.toLocaleLowerCase(), + })), + ]} + onChange={(value) => { + setStudioFilter(value); + resetPage(); + }} + /> + )} + {mediaFilter === 'tv' && ( + ({ + label: network.name, + value: network.name.toLocaleLowerCase(), + })), + ]} + onChange={(value) => { + setNetworkFilter(value); + resetPage(); + }} + /> + )} + {mediaFilter === 'music' && ( + { + setAlbumTypeFilter(value); + resetPage(); + }} + /> + )} +
    + ), + }, + { + section: 'sortBy', + label: intl.formatMessage(messages.sortBy), + children: ( +
    + {( + [ + ['added', messages.sortDate], + ['modified', messages.sortModified], + ['status', messages.sortStatus], + ] as const + ).map(([value, label]) => { + const active = sort === value; + const Icon = + active && direction === 'asc' + ? BarsArrowUpIcon + : BarsArrowDownIcon; + return ( + + ); + })} +
    + ), + }, + ]} + />
    {data.results .filter((issue) => issue.id !== focusedIssueId) diff --git a/src/components/IssueModal/CreateIssueModal/index.tsx b/src/components/IssueModal/CreateIssueModal/index.tsx index 0f0e3a95b..05beba4e3 100644 --- a/src/components/IssueModal/CreateIssueModal/index.tsx +++ b/src/components/IssueModal/CreateIssueModal/index.tsx @@ -448,7 +448,7 @@ const CreateIssueModal = ({ buttonSize="standard" > - @@ -465,7 +465,7 @@ const CreateIssueModal = ({ } > - diff --git a/src/components/IssueModal/index.tsx b/src/components/IssueModal/index.tsx index 953ddc847..b6167c9a1 100644 --- a/src/components/IssueModal/index.tsx +++ b/src/components/IssueModal/index.tsx @@ -23,12 +23,7 @@ const IssueModal = ({ }: IssueModalProps) => ( { as="button" type="button" onClick={toggleMode} - className="app-button app-button-default mb-3 flex w-full justify-between px-3 py-2 text-sm" + className="app-button app-button-default button-standard mb-3 flex w-full justify-between px-3 py-2 text-sm" > - {mode === 'dark' ? ( - - ) : ( - - )} + {mode === 'dark' ? : } {mode === 'dark' ? intl.formatMessage(messages.darkMode) : intl.formatMessage(messages.lightMode)} diff --git a/src/components/Layout/index.tsx b/src/components/Layout/index.tsx index 7aee4bdd3..8b469bd17 100644 --- a/src/components/Layout/index.tsx +++ b/src/components/Layout/index.tsx @@ -6,35 +6,25 @@ import ThemePicker from '@app/components/Layout/ThemePicker'; import UserDropdown from '@app/components/Layout/UserDropdown'; import UserWarnings from '@app/components/Layout/UserWarnings'; import useLocale from '@app/hooks/useLocale'; -import useSearchActivity from '@app/hooks/useSearchActivity'; import useSettings from '@app/hooks/useSettings'; import { Permission, useUser } from '@app/hooks/useUser'; -import defineMessages from '@app/utils/defineMessages'; import { DISCOVER_MEDIA_TYPES, isConfiguredMediaCategoryEnabled, isDiscoverMediaTypeEnabled, isOptionalCatalogPathEnabled, } from '@app/utils/serviceAvailability'; -import { ArrowPathIcon } from '@heroicons/react/24/outline'; import { ArrowLeftIcon, Bars3BottomLeftIcon } from '@heroicons/react/24/solid'; import type { AvailableLocale } from '@server/types/languages'; import { useRouter } from 'next/router'; import { useEffect, useRef, useState } from 'react'; -import { useIntl } from 'react-intl'; import useSWR from 'swr'; type LayoutProps = { children: React.ReactNode; }; -const messages = defineMessages('components.Layout', { - searching: 'Searching', -}); - const Layout = ({ children }: LayoutProps) => { - const intl = useIntl(); - const isSearching = useSearchActivity(); const [isSidebarOpen, setSidebarOpen] = useState(false); const [isScrolled, setIsScrolled] = useState(false); const isScrolledRef = useRef(false); @@ -289,25 +279,11 @@ const Layout = ({ children }: LayoutProps) => {
    -
    +
    -
    +
    -
    -
    - {isSearching && ( - <> - - {intl.formatMessage(messages.searching)} - - )} -
    - {children} -
    + {children}
    diff --git a/src/components/Login/AddEmailModal.tsx b/src/components/Login/AddEmailModal.tsx index ed0b98558..381c904e8 100644 --- a/src/components/Login/AddEmailModal.tsx +++ b/src/components/Login/AddEmailModal.tsx @@ -45,17 +45,7 @@ const AddEmailModal: React.FC = ({ onClose, onSave }) => { }); return ( - + { ); expect(html).toContain('data-confirm="danger"'); expect(html).toContain('data-cancel="success"'); - expect(html).toContain('Permanently delete Movie from Radarr (4K)?'); + expect(html).toContain('

    Permanently Delete Movie from Radarr (4K)?

    '); expect(html).toContain( 'permanently delete its media files and remove its library entry' ); diff --git a/src/components/ManageSlideOver/manageActions.test.mjs b/src/components/ManageSlideOver/manageActions.test.mjs index edbea15e4..d1648f339 100644 --- a/src/components/ManageSlideOver/manageActions.test.mjs +++ b/src/components/ManageSlideOver/manageActions.test.mjs @@ -1,8 +1,55 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import test from 'node:test'; +import { styleContract } from '../../styles/cssContract.mjs'; const read = (path) => readFileSync(new URL(path, import.meta.url), 'utf8'); + +// Shared spacing is configured once, then consumed by card and action roles. +// Check that ownership chain rather than requiring a copied literal per role. +const assertSharedManageSpacing = (css) => { + const contract = styleContract(css); + const declaration = (selector, property, expected) => { + assert.equal( + contract.declaration(selector, property), + expected, + `${selector} ${property}` + ); + }; + declaration(':root', '--card-layout-spacing', '8px'); + declaration(':root', '--card-spacing', 'var(--card-layout-spacing)'); + declaration(':root', '--main-card-padding', '8px'); + declaration(':root', '--inset-card-padding', '8px'); + for (const selector of [ + '.card-layout', + '.app-card-main', + '.app-card-sub', + '.app-card-inset', + ]) { + declaration(selector, '--card-spacing', 'var(--card-layout-spacing)'); + } + for (const selector of [ + '.manage-request-actions', + '.manage-advanced-section', + '.manage-media-stack', + '.manage-issues-list', + ]) { + declaration(selector, 'gap', 'var(--card-spacing)'); + } + declaration( + '.card-stack > :not([hidden]) ~ :not([hidden])', + 'margin-block-start', + 'var(--card-spacing)' + ); + declaration('.card-spacing-before', 'margin-top', 'var(--card-spacing)'); + declaration('.card-spacing-after', 'margin-bottom', 'var(--card-spacing)'); + declaration( + '.manage-media-dialog', + 'padding', + 'var(--main-card-padding) !important' + ); + declaration('.manage-issues-panel', 'padding', 'var(--inset-card-padding)'); +}; test('Manage icons, counts and disclosure share the global controls', () => { const source = read('./ManageMediaActions.tsx'); assert.match(source, /
    \s*

    \s*\{displayTitle\}/ - ); + const titleCard = source + .match( + /

    \s*

    \s*\{displayTitle\}/ + )?.[1] + .split(/\s+/); + for (const role of [ + 'app-card-inset', + 'app-card-sub', + 'refreshed-inset-surface', + 'request-action-explanation', + ]) { + assert.ok( + titleCard?.includes(role), + `Missing confirmation title-card role: ${role}` + ); + } assert.doesNotMatch(source, /issues\}\} for \{title\}/); assert.match( read('./index.tsx'), @@ -85,7 +144,23 @@ test('confirmation title cards use media title/year rather than service IDs', () }); test('issue navigation shares the same green color rule as Previous and Next controls', () => { const css = read('../../styles/globals.css'); - assert.match(css, /\.app-button-success\s*\{\s*@apply border-green-500/); + const contract = styleContract(css); + assert.equal( + contract.declaration('.app-button-success', 'border-color'), + 'color-mix(in srgb, var(--palette-green) 90%, transparent)' + ); + assert.equal( + contract.declaration('.app-button-success', 'color'), + 'hsl(119 52% 74%)' + ); + assert.equal( + contract.declaration('.app-button-success:hover', 'color'), + '#fff' + ); + assert.equal( + contract.declaration('.app-button-success:active', 'color'), + 'hsl(119 52% 74%)' + ); assert.match( read('./ManageMediaActions.tsx'), /buttonType="success"\s*disabled=\{!canViewIssues/ @@ -105,10 +180,29 @@ test('Manage uses yellow for closing issues and red for destructive confirmation test('library deletion shares red styling and disabled Manage issue actions keep semantic colors', () => { const css = read('../../styles/globals.css'); - assert.match( - css, - /\.app-button-danger,\s*\.request-destructive-action-delete,\s*\.request-destructive-action-remove\s*\{\s*@apply border-red-500/ + const contract = styleContract(css); + assert.equal( + contract.declaration('.app-button-danger', 'color'), + 'hsl(0 66% 76%)' ); + for (const role of [ + '.request-destructive-action-delete', + '.request-destructive-action-remove', + ]) { + for (const state of ['', ':hover', ':active']) { + for (const property of ['border-color', 'background-color', 'color']) { + const expected = contract.declaration( + `.app-button-danger${state}`, + property + ); + assert.ok(expected, `Missing destructive owner: ${property}${state}`); + assert.equal( + contract.declaration(`${role}${state}`, property), + expected + ); + } + } + } assert.doesNotMatch( css, /\.manage-advanced-sections \.app-button-\w+:disabled/ @@ -126,9 +220,19 @@ test('disabled View Issues shares normal disabled styling without text or icon s const css = read('../../styles/globals.css'); assert.match(css, /button\.app-button:disabled\s*\{\s*text-shadow: none;/); assert.match(css, /button\.app-button:disabled svg\s*\{\s*filter: none;/); - const base = css.match(/\.app-button\s*\{([^}]*)\}/s)?.[1]; - assert.match(base, /disabled:opacity-60/); - assert.doesNotMatch(base, /disabled:brightness|disabled:grayscale/); + const contract = styleContract(css); + assert.equal(contract.declaration('.app-button:disabled', 'opacity'), '0.6'); + assert.equal( + contract.declaration('.app-button:disabled', 'cursor'), + 'not-allowed' + ); + assert.equal( + contract.declaration('.app-button:disabled', 'filter'), + undefined + ); + for (const token of contract.applies('.app-button')) { + assert.doesNotMatch(token, /disabled:brightness|disabled:grayscale/); + } }); test('English wording uses the current catalog instead of a preserved state snapshot', () => { @@ -167,12 +271,34 @@ for (const file of [ } test('global Manage spacing separates 8px padding from gaps and the issue action is not clipped', () => { const css = read('../../styles/globals.css'); - assert.match(css, /--card-spacing: 8px/); - assert.match(css, /padding: var\(--main-card-padding\) !important/); - assert.match(css, /gap: var\(--card-spacing\)/); + assertSharedManageSpacing(css); assert.match(css, /\.issue-action-value\s*\{[^}]*overflow: visible/s); }); +test('shared Manage spacing check rejects changed tokens and disconnected gap or margin roles', () => { + const css = read('../../styles/globals.css'); + for (const [before, after] of [ + ['--card-layout-spacing: 8px;', '--card-layout-spacing: 12px;'], + ['--card-spacing: var(--card-layout-spacing);', '--card-spacing: 12px;'], + ['--main-card-padding: 8px;', '--main-card-padding: 12px;'], + ['--inset-card-padding: 8px;', '--inset-card-padding: 12px;'], + ['gap: var(--card-spacing);', 'gap: 12px;'], + ['margin-block-start: var(--card-spacing);', 'margin-block-start: 12px;'], + ['margin-top: var(--card-spacing);', 'margin-top: 12px;'], + ['margin-bottom: var(--card-spacing);', 'margin-bottom: 12px;'], + ]) { + assert.ok( + css.includes(before), + `Missing negative-check fixture: ${before}` + ); + assert.throws( + () => assertSharedManageSpacing(css.replaceAll(before, after)), + undefined, + before + ); + } +}); + test('Manage buttons no longer show a red issue dot for any media type', () => { for (const type of ['Movie', 'Tv', 'Book', 'Music']) { assert.doesNotMatch(read(`../${type}Details/index.tsx`), /animate-ping/); diff --git a/src/components/MediaDetails/DetailDisclosureButton.tsx b/src/components/MediaDetails/DetailDisclosureButton.tsx index 1efb6d345..7391008e7 100644 --- a/src/components/MediaDetails/DetailDisclosureButton.tsx +++ b/src/components/MediaDetails/DetailDisclosureButton.tsx @@ -1,7 +1,7 @@ import Tooltip from '@app/components/Common/Tooltip'; import defineMessages from '@app/utils/defineMessages'; import { ChevronDownIcon } from '@heroicons/react/24/outline'; -import type { ReactNode, SVGProps } from 'react'; +import type { ButtonHTMLAttributes, ReactNode, SVGProps } from 'react'; import { useIntl } from 'react-intl'; export const PushPinIcon = ({ @@ -26,7 +26,7 @@ const messages = defineMessages('components.MediaDetails.DetailDisclosure', { unpin: 'Unpin {label}', }); -interface DetailDisclosureButtonProps { +export interface DetailDisclosureButtonProps { label: string; icon?: ReactNode; open: boolean; @@ -34,6 +34,23 @@ interface DetailDisclosureButtonProps { pinned?: boolean; onPinClick?: () => void; controls?: string; + title?: string; + reorder?: { + role: string; + state?: 'ready' | 'dragging'; + insertion?: 'before' | 'after'; + descriptionId: string; + handlers: Pick< + ButtonHTMLAttributes, + | 'onPointerDown' + | 'onPointerMove' + | 'onPointerUp' + | 'onPointerCancel' + | 'onLostPointerCapture' + | 'onKeyDown' + | 'onClickCapture' + >; + }; } const DetailDisclosureButton = ({ @@ -44,6 +61,8 @@ const DetailDisclosureButton = ({ pinned = false, onPinClick, controls, + title, + reorder, }: DetailDisclosureButtonProps) => { const intl = useIntl(); const pinLabel = intl.formatMessage(pinned ? messages.unpin : messages.pin, { @@ -51,7 +70,12 @@ const DetailDisclosureButton = ({ }); return ( - + {onPinClick && ( @@ -74,7 +98,10 @@ const DetailDisclosureButton = ({ className="detail-disclosure-button" aria-expanded={open} aria-controls={controls} + title={title} onClick={onClick} + aria-describedby={reorder?.descriptionId} + {...reorder?.handlers} > {icon} {label} diff --git a/src/components/MediaDetails/ExpandableCreditList.tsx b/src/components/MediaDetails/ExpandableCreditList.tsx index 927e04ab1..f0f886653 100644 --- a/src/components/MediaDetails/ExpandableCreditList.tsx +++ b/src/components/MediaDetails/ExpandableCreditList.tsx @@ -1,4 +1,7 @@ import CachedImage from '@app/components/Common/CachedImage'; +import PageErrorMessage, { + type MessageRetry, +} from '@app/components/Common/PageErrorMessage'; import Link from 'next/link'; export interface ExpandableCredit { @@ -12,27 +15,33 @@ interface ExpandableCreditListProps { title: string; credits: ExpandableCredit[]; emptyLabel: string; + retry?: MessageRetry; } const ExpandableCreditList = ({ title, credits, emptyLabel, + retry, }: ExpandableCreditListProps) => ( -

    -

    {title}

    +
    +

    {title}

    {credits.length === 0 ? ( -

    {emptyLabel}

    + ) : ( -
    +
    {credits.map((credit, index) => ( - - + - - + + {credit.name} - - - {credit.role} - + + {credit.role} - +
    ))}
    )} diff --git a/src/components/MediaDetails/MediaDetailArtwork.tsx b/src/components/MediaDetails/MediaDetailArtwork.tsx index 7f8520be7..92101a4f6 100644 --- a/src/components/MediaDetails/MediaDetailArtwork.tsx +++ b/src/components/MediaDetails/MediaDetailArtwork.tsx @@ -33,7 +33,7 @@ const MediaDetailArtwork = ({ src, type }: MediaDetailArtworkProps) => { fill priority sizes="100vw" - className="media-detail-artwork-image object-cover object-top" + className="media-detail-artwork-image" />
    diff --git a/src/components/MediaDetails/MediaServerCollectionButton.tsx b/src/components/MediaDetails/MediaServerCollectionButton.tsx new file mode 100644 index 000000000..713aa7fb8 --- /dev/null +++ b/src/components/MediaDetails/MediaServerCollectionButton.tsx @@ -0,0 +1,310 @@ +import Dropdown from '@app/components/Common/Dropdown'; +import MediaServerIcon, { + getMediaServerName, +} from '@app/components/Common/MediaServerIcon'; +import PageErrorMessage from '@app/components/Common/PageErrorMessage'; +import Tooltip from '@app/components/Common/Tooltip'; +import useSettings from '@app/hooks/useSettings'; +import { useUser, type User } from '@app/hooks/useUser'; +import defineMessages from '@app/utils/defineMessages'; +import type { MediaServerType } from '@server/constants/server'; +import type { MediaServerCollectionsStatus } from '@server/models/MediaServerCollections'; +import axios from 'axios'; +import { useEffect, useRef, useState } from 'react'; +import { useIntl } from 'react-intl'; +import useSWR from 'swr'; + +const messages = defineMessages( + 'components.MediaDetails.MediaServerCollectionButton', + { + addCollection: 'Add to Collection', + addNamed: 'Add to {name}', + removeNamed: 'Remove from {name}', + help: 'Choose an existing shared {server} collection to add or remove this entire series. Selected episodes are not added separately.', + link: 'Link your own {server} account before changing its collections.', + permission: + 'Your linked {server} account does not have permission to manage these shared collections.', + missing: + 'This series is not available in your accessible {server} library for the selected quality.', + limit: + 'The {server} collection list exceeds the supported limit. No partial list is offered.', + empty: + 'Create a collection in {server} first. SeerrNG only adds titles to existing collections.', + busy: 'The {server} collection state is loading or updating.', + unavailable: 'Collections are unavailable for the configured media server.', + errorTitle: 'Media Server Collections Unavailable', + error: + 'The collections could not be loaded or the change could not be confirmed. Reload their current state before changing membership.', + retry: + 'Reload collection membership from your linked media-server account. This does not repeat an add or remove action.', + } +); + +export interface MediaServerCollectionButtonProps { + tvId: number; + is4k?: boolean; + enabled?: boolean; + onLoadingChange?: (loading: boolean) => void; +} +type Envelope = { context: string; status: MediaServerCollectionsStatus }; +type ReadKey = [string, string, MediaServerType]; +const freshReadConfig = { headers: { 'Cache-Control': 'no-cache' } } as const; +const identityKey = (user: User | undefined): string => + JSON.stringify( + user + ? [ + user.id, + user.plexId ?? null, + user.plexUsername ?? null, + user.jellyfinUsername ?? null, + String(user.updatedAt), + ] + : [] + ); +const record = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value); +const confirmedStatus = ( + value: unknown, + serverType: MediaServerType +): MediaServerCollectionsStatus => { + const reasons = [ + 'account-not-linked', + 'not-authorized', + 'series-not-found', + 'unsupported-server', + 'collection-limit', + ]; + if ( + !record(value) || + value.serverType !== serverType || + typeof value.available !== 'boolean' || + !Array.isArray(value.collections) || + value.collections.length > 500 || + (value.reason !== undefined && !reasons.includes(String(value.reason))) || + (!value.available && value.collections.length !== 0) + ) + throw new Error('Collection status could not be verified.'); + if (value.available && value.reason !== undefined) + throw new Error('Inconsistent collection capability.'); + const seen = new Set(); + for (const option of value.collections) { + if ( + !record(option) || + typeof option.id !== 'string' || + !/^[a-z\d-]{1,128}$/i.test(option.id) || + typeof option.name !== 'string' || + !option.name.trim() || + option.name.length > 512 || + typeof option.member !== 'boolean' || + seen.has(option.id) + ) + throw new Error('Collection membership could not be verified.'); + seen.add(option.id); + } + return value as unknown as MediaServerCollectionsStatus; +}; + +const ActiveMediaServerCollectionButton = ({ + tvId, + is4k = false, + onLoadingChange, +}: MediaServerCollectionButtonProps) => { + const intl = useIntl(); + const settings = useSettings(); + const { + user, + loading: userLoading, + error: userError, + revalidate: revalidateUser, + } = useUser(); + const serverType = settings.currentSettings.mediaServerType; + const server = getMediaServerName(serverType); + const endpoint = `/api/v1/tv/${tvId}/media-server-collections`; + const readEndpoint = `${endpoint}?is4k=${is4k}`; + const linkedIdentity = identityKey(user); + const context = JSON.stringify([tvId, is4k, serverType, linkedIdentity]); + const currentContext = useRef(context); + currentContext.current = context; + const mounted = useRef(true); + const inFlight = useRef(false); + const [updating, setUpdating] = useState(false); + const [failureContext, setFailureContext] = useState(); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + const { data, error, isLoading, isValidating, mutate } = useSWR( + user && !userError && server ? [readEndpoint, context, serverType] : null, + async ([url, requestContext, requestType]: ReadKey) => ({ + context: requestContext, + status: confirmedStatus( + (await axios.get(url, freshReadConfig)).data, + requestType + ), + }), + { shouldRetryOnError: false } + ); + const status = data?.context === context ? data.status : undefined; + const busy = userLoading || isLoading || isValidating || updating; + const failed = !!error || !!userError || failureContext === context; + const disabled = + busy || failed || !user || !status?.available || !status.collections.length; + useEffect(() => { + setFailureContext(undefined); + }, [context]); + useEffect(() => { + onLoadingChange?.(busy); + return () => onLoadingChange?.(false); + }, [busy, onLoadingChange]); + + const perform = async (choice?: { id: string; member: boolean }) => { + if (inFlight.current || busy || (choice && disabled)) return; + const actionContext = context; + if (currentContext.current !== actionContext) return; + inFlight.current = true; + setUpdating(true); + const stillCurrent = () => + mounted.current && currentContext.current === actionContext; + try { + const freshUser = await revalidateUser(); + if (!stillCurrent()) return; + if (!freshUser || identityKey(freshUser) !== linkedIdentity) + throw new Error('Linked account changed.'); + const freshStatus = confirmedStatus( + (await axios.get(readEndpoint, freshReadConfig)).data, + serverType + ); + if (!stillCurrent()) return; + let confirmed = freshStatus; + if (choice) { + const existing = freshStatus.collections.find( + (option) => option.id === choice.id + ); + if (!freshStatus.available || !existing) + throw new Error('Collection is no longer accessible.'); + // Preserve the explicitly chosen action if another client changed state. + // Never invert fresh membership and accidentally reverse the user's intent. + if (existing.member !== choice.member) { + confirmed = confirmedStatus( + ( + await axios.post( + `${endpoint}/${encodeURIComponent(choice.id)}`, + { member: choice.member, is4k } + ) + ).data, + serverType + ); + if (!stillCurrent()) return; + } + if ( + !confirmed.available || + confirmed.collections.find((option) => option.id === choice.id) + ?.member !== choice.member + ) + throw new Error('Collection update was not confirmed.'); + } + await mutate( + { context: actionContext, status: confirmed }, + { revalidate: false } + ); + if (stillCurrent()) setFailureContext(undefined); + } catch { + if (stillCurrent()) setFailureContext(actionContext); + } finally { + inFlight.current = false; + if (mounted.current) setUpdating(false); + } + }; + if (!server) return null; + const reasonMessage = + status?.reason === 'account-not-linked' || !user + ? messages.link + : status?.reason === 'not-authorized' + ? messages.permission + : status?.reason === 'series-not-found' + ? messages.missing + : status?.reason === 'collection-limit' + ? messages.limit + : messages.unavailable; + const help = intl.formatMessage( + busy + ? messages.busy + : failed + ? messages.error + : !status?.available + ? reasonMessage + : !status.collections.length + ? messages.empty + : messages.help, + { server } + ); + return ( + <> + + + + + {intl.formatMessage(messages.addCollection)} + + } + > + {status?.collections.length + ? status.collections.map((option) => ( + { + event.preventDefault(); + void perform({ id: option.id, member: !option.member }); + }} + > + {intl.formatMessage( + option.member ? messages.removeNamed : messages.addNamed, + { name: option.name } + )} + + )) + : undefined} + + + + {failed && ( + perform(), + }} + /> + )} + + ); +}; + +// A disabled integration boundary renders nothing and mounts no data hooks. +const MediaServerCollectionButton = ( + props: MediaServerCollectionButtonProps +) => + props.enabled === false || + !Number.isSafeInteger(props.tvId) || + props.tvId < 1 || + props.tvId > 1_000_000_000 || + (props.is4k !== undefined && typeof props.is4k !== 'boolean') ? null : ( + + ); +export default MediaServerCollectionButton; diff --git a/src/components/MediaDetails/MediaServerWatchlistButton.tsx b/src/components/MediaDetails/MediaServerWatchlistButton.tsx new file mode 100644 index 000000000..333cca562 --- /dev/null +++ b/src/components/MediaDetails/MediaServerWatchlistButton.tsx @@ -0,0 +1,278 @@ +import Button from '@app/components/Common/Button'; +import MediaServerIcon, { + getMediaServerName, +} from '@app/components/Common/MediaServerIcon'; +import PageErrorMessage from '@app/components/Common/PageErrorMessage'; +import Tooltip from '@app/components/Common/Tooltip'; +import useSettings from '@app/hooks/useSettings'; +import { useUser } from '@app/hooks/useUser'; +import defineMessages from '@app/utils/defineMessages'; +import { MediaServerType } from '@server/constants/server'; +import type { MediaServerSavedItemStatus } from '@server/models/MediaServerSavedItem'; +import axios from 'axios'; +import { useEffect, useRef, useState } from 'react'; +import { useIntl } from 'react-intl'; +import useSWR from 'swr'; + +const messages = defineMessages( + 'components.MediaDetails.MediaServerWatchlistButton', + { + addWatchlist: 'Add to Watchlist', + removeWatchlist: 'Remove from Watchlist', + addFavorites: 'Add to Favorites', + removeFavorites: 'Remove from Favorites', + addHelp: + 'Add this entire series to your {server} {list}. Selected seasons and episodes are not added separately.', + removeHelp: + 'Remove this entire series from your {server} {list}. This does not remove media or change your Seerr watchlist.', + linkHelp: 'Link your own {server} account before saving this series.', + missingHelp: + 'This series could not be matched in your accessible {server} catalog.', + pendingHelp: 'The {server} saved state is loading or updating.', + failedTitle: 'Media Server Saved State Unavailable', + failed: + 'The saved state could not be loaded or updated. Please retry before changing it.', + retry: + 'Reload the saved state from your own media-server account. This does not repeat an add or remove action.', + } +); + +type SavedItemProps = { + tvId: number; + is4k?: boolean; + enabled?: boolean; + onLoadingChange?: (loading: boolean) => void; +}; +type SavedEnvelope = { context: string; status: MediaServerSavedItemStatus }; +type LinkedIdentity = { + id: number; + plexId?: number | null; + plexUsername?: string | null; + jellyfinUsername?: string | null; + updatedAt: Date; +}; +const identityKey = (user: LinkedIdentity | undefined): string => + JSON.stringify( + user + ? [ + user.id, + user.plexId ?? null, + user.plexUsername ?? null, + user.jellyfinUsername ?? null, + String(user.updatedAt), + ] + : [] + ); +const confirmedStatus = ( + value: unknown, + serverType: MediaServerType +): MediaServerSavedItemStatus => { + const status = value as MediaServerSavedItemStatus | undefined; + if ( + !status || + status.serverType !== serverType || + status.kind !== + (serverType === MediaServerType.PLEX ? 'watchlist' : 'favorites') || + typeof status.available !== 'boolean' || + (status.available && typeof status.saved !== 'boolean') + ) + throw new Error('Native saved state could not be verified.'); + return status; +}; +const freshReadConfig = { + headers: { 'Cache-Control': 'no-cache' }, +} as const; + +const ActiveMediaServerWatchlistButton = ({ + tvId, + is4k = false, + onLoadingChange, +}: SavedItemProps) => { + const intl = useIntl(); + const settings = useSettings(); + const { + user, + loading: userLoading, + error: userError, + revalidate: revalidateUser, + } = useUser(); + const configuredType = settings.currentSettings.mediaServerType; + const endpoint = `/api/v1/tv/${tvId}/media-server-saved-item`; + const readEndpoint = `${endpoint}?is4k=${is4k}`; + const linkedIdentity = identityKey(user); + const context = JSON.stringify([tvId, is4k, configuredType, linkedIdentity]); + const currentContext = useRef(context); + currentContext.current = context; + const mounted = useRef(true); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + const { data, error, isLoading, isValidating, mutate } = + useSWR( + user && !userError && configuredType !== MediaServerType.NOT_CONFIGURED + ? [readEndpoint, context] + : null, + async ([url, requestContext]: [string, string]) => ({ + context: requestContext, + status: confirmedStatus( + (await axios.get(url, freshReadConfig)).data, + configuredType + ), + }), + { shouldRetryOnError: false } + ); + const [updating, setUpdating] = useState(false); + const [failureContext, setFailureContext] = useState(); + const inFlight = useRef(false); + const status = data?.context === context ? data.status : undefined; + const busy = userLoading || isLoading || isValidating || updating; + useEffect(() => { + setFailureContext(undefined); + }, [context]); + useEffect(() => { + onLoadingChange?.(busy); + return () => onLoadingChange?.(false); + }, [busy, onLoadingChange]); + if (configuredType === MediaServerType.NOT_CONFIGURED) return null; + const serverType = configuredType; + const server = getMediaServerName(serverType); + if (!server) return null; + const watchlist = serverType === MediaServerType.PLEX; + const saved = status?.saved; + const failed = !!error || !!userError || failureContext === context; + const disabled = + busy || failed || !user || !status?.available || typeof saved !== 'boolean'; + const help = intl.formatMessage( + busy + ? messages.pendingHelp + : failed + ? messages.failed + : status?.reason === 'account-not-linked' + ? messages.linkHelp + : !status?.available + ? messages.missingHelp + : saved + ? messages.removeHelp + : messages.addHelp, + { server, list: watchlist ? 'Watchlist' : 'Favorites' } + ); + const toggle = async () => { + if (disabled || inFlight.current) return; + const actionContext = context; + if (currentContext.current !== actionContext) return; + inFlight.current = true; + setUpdating(true); + setFailureContext(undefined); + const stillCurrent = () => + mounted.current && currentContext.current === actionContext; + try { + const freshUser = await revalidateUser(); + if (!stillCurrent()) return; + if (!freshUser || identityKey(freshUser) !== linkedIdentity) + throw new Error('Linked account changed.'); + const freshStatus = confirmedStatus( + (await axios.get(readEndpoint, freshReadConfig)).data, + configuredType + ); + if (!stillCurrent()) return; + if (!freshStatus.available || typeof freshStatus.saved !== 'boolean') + throw new Error('Native saving is unavailable.'); + const desired = !saved; + const confirmed = + freshStatus.saved === desired + ? freshStatus + : confirmedStatus( + (await axios.post(endpoint, { saved: desired, is4k })) + .data, + configuredType + ); + if (!stillCurrent()) return; + if (!confirmed.available || confirmed.saved !== desired) + throw new Error('Native saved-state change was not confirmed.'); + await mutate( + { context: actionContext, status: confirmed }, + { revalidate: false } + ); + } catch { + if (stillCurrent()) setFailureContext(actionContext); + } finally { + inFlight.current = false; + if (mounted.current) setUpdating(false); + } + }; + return ( + <> + + + + + + {failed && ( + { + if (busy || inFlight.current) return; + const retryContext = context; + inFlight.current = true; + setUpdating(true); + setFailureContext(undefined); + try { + await revalidateUser(); + if (mounted.current && currentContext.current === retryContext) + await mutate(); + } catch { + if (mounted.current && currentContext.current === retryContext) + setFailureContext(retryContext); + } finally { + inFlight.current = false; + if (mounted.current) setUpdating(false); + } + }, + }} + /> + )} + + ); +}; + +const MediaServerWatchlistButton = (props: SavedItemProps) => + props.enabled === false || + !Number.isSafeInteger(props.tvId) || + props.tvId < 1 || + props.tvId > 1_000_000_000 || + (props.is4k !== undefined && typeof props.is4k !== 'boolean') ? null : ( + + ); + +export default MediaServerWatchlistButton; diff --git a/src/components/MediaDetails/MovieSummaryCard.tsx b/src/components/MediaDetails/MovieSummaryCard.tsx index 0f049cc17..129fbbe15 100644 --- a/src/components/MediaDetails/MovieSummaryCard.tsx +++ b/src/components/MediaDetails/MovieSummaryCard.tsx @@ -159,6 +159,7 @@ const MovieSummaryCard = ({
    diff --git a/src/components/MediaDetails/ReorderableDisclosureRow.tsx b/src/components/MediaDetails/ReorderableDisclosureRow.tsx new file mode 100644 index 000000000..ed30b9876 --- /dev/null +++ b/src/components/MediaDetails/ReorderableDisclosureRow.tsx @@ -0,0 +1,439 @@ +import defineMessages from '@app/utils/defineMessages'; +import { + insertDisclosureRole, + type SeriesDisclosureRole, +} from '@server/utils/detailDisclosureOrder'; +import { + Children, + cloneElement, + useCallback, + useEffect, + useId, + useLayoutEffect, + useRef, + useState, + type CSSProperties, + type ReactElement, + type ReactNode, +} from 'react'; +import { createPortal } from 'react-dom'; +import { useIntl } from 'react-intl'; +import type { DetailDisclosureButtonProps } from './DetailDisclosureButton'; + +export const DISCLOSURE_DRAG_HOLD_MS = 1000; +const messages = defineMessages('components.MediaDetails.DisclosureOrder', { + instructions: + 'Hold a button for one second to move it. Or use Shift + Alt + Left or Right Arrow to reorder. Press Escape to cancel.', + ready: '{label} is ready to move. Drag it between the other buttons.', + moved: '{label} moved to position {position}.', + failed: + 'Your button order could not be saved. Your previous order has been restored. Please try again.', +}); +type Item = ReactElement; +type Anchor = { role: SeriesDisclosureRole; bounds: DOMRect }; +type Gesture = { + role: SeriesDisclosureRole; + pointerId: number; + x: number; + y: number; + ready: boolean; + moved: boolean; + button: HTMLButtonElement; + row: HTMLElement; + width: number; + height: number; + offsetX: number; + offsetY: number; + original: SeriesDisclosureRole[]; + draft: SeriesDisclosureRole[]; + anchors: Anchor[]; + snapshot: HTMLElement; + commit: (next: SeriesDisclosureRole[]) => void; + timer?: ReturnType; +}; +type Ghost = { + role: SeriesDisclosureRole; + x: number; + y: number; + width: number; + height: number; +}; + +export const OrderedDisclosurePanels = ({ + order, + children, +}: { + order: readonly SeriesDisclosureRole[]; + children: ReactNode; +}) => { + const panels = Children.toArray(children) as ReactElement[]; + // Only committed preference order moves panels; drafts affect the button row alone. + return ( + <> + {order.map((role) => + panels.find((panel) => String(panel.key).endsWith('$' + role)) + )} + + ); +}; + +const ReorderableDisclosureRow = ({ + order, + onOrderChange, + children, + leading, + distributed = false, + disabled = false, +}: { + order: readonly SeriesDisclosureRole[]; + onOrderChange: (order: SeriesDisclosureRole[]) => Promise; + children: (Item | false | null)[]; + leading?: ReactNode; + distributed?: boolean; + disabled?: boolean; +}) => { + const intl = useIntl(); + const descriptionId = useId(); + const gesture = useRef(undefined); + const suppressClick = useRef(undefined); + const [active, setActive] = useState<{ + role: SeriesDisclosureRole; + state: 'ready' | 'dragging'; + }>(); + const [draft, setDraft] = useState(); + const [ghost, setGhost] = useState(); + const ghostContainer = useRef(null); + const [announcement, setAnnouncement] = useState(''); + const [saving, setSaving] = useState(false); + const items = new Map( + children + .filter((child): child is Item => Boolean(child)) + .map((child) => [String(child.key), child]) + ); + const ghostRole = ghost?.role; + useLayoutEffect(() => { + const snapshot = gesture.current?.snapshot; + if (ghostRole && snapshot) + ghostContainer.current?.replaceChildren(snapshot); + }, [ghostRole]); + + const cancel = useCallback(() => { + const current = gesture.current; + gesture.current = undefined; + clearTimeout(current?.timer); + if (current?.row.hasPointerCapture?.(current.pointerId)) + current.row.releasePointerCapture(current.pointerId); + setActive(undefined); + setDraft(undefined); + setGhost(undefined); + }, []); + const placementAt = useCallback((current: Gesture, x: number, y: number) => { + const pointed = document.elementFromPoint(x, y); + if ( + !pointed || + pointed.closest('.media-detail-disclosure-row') !== current.row + ) + return undefined; + // Freeze geometry before reflow. Siblings moving under a stationary pointer + // cannot oscillate the insertion slot back and forth. + const anchors = current.anchors.filter( + (anchor) => anchor.role !== current.role + ); + const target = [...anchors].sort((a, b) => { + const distance = (anchor: Anchor) => + Math.hypot( + x - (anchor.bounds.left + anchor.bounds.width / 2), + y - (anchor.bounds.top + anchor.bounds.height / 2) + ); + return distance(a) - distance(b); + })[0]; + if (!target) return [...current.original]; + return insertDisclosureRole( + current.original, + current.role, + target.role, + x > target.bounds.left + target.bounds.width / 2 + ); + }, []); + const movePointer = useCallback( + (event: PointerEvent) => { + const current = gesture.current; + if (!current || current.pointerId !== event.pointerId) return; + if (!current.ready) { + if ( + Math.hypot(event.clientX - current.x, event.clientY - current.y) > 8 + ) + cancel(); + return; + } + if ( + event.clientX === current.x && + event.clientY === current.y && + !current.moved + ) + return; + event.preventDefault(); + current.moved = true; + setActive({ role: current.role, state: 'dragging' }); + setGhost({ + role: current.role, + x: event.clientX - current.offsetX, + y: event.clientY - current.offsetY, + width: current.width, + height: current.height, + }); + const next = placementAt(current, event.clientX, event.clientY); + current.draft = next ?? [...current.original]; + setDraft(current.draft); + }, + [cancel, placementAt] + ); + const finishPointer = useCallback( + (event: PointerEvent) => { + const current = gesture.current; + if (!current || current.pointerId !== event.pointerId) return; + suppressClick.current = current.ready ? current.role : undefined; + // Revalidate release coordinates; never commit a stale last-hover target. + const next = + current.ready && current.moved + ? placementAt(current, event.clientX, event.clientY) + : undefined; + cancel(); + if (next) current.commit(next); + }, + [cancel, placementAt] + ); + useEffect(() => { + const escape = (event: KeyboardEvent) => { + if (event.key === 'Escape' && gesture.current) { + suppressClick.current = gesture.current.ready + ? gesture.current.role + : undefined; + cancel(); + } + }; + const abort = (event: Event) => { + if ( + event.type === 'pointercancel' && + (event as PointerEvent).pointerId !== gesture.current?.pointerId + ) + return; + if (gesture.current?.ready) suppressClick.current = gesture.current.role; + cancel(); + }; + const lostCapture = (event: PointerEvent) => { + const current = gesture.current; + if ( + current && + event.pointerId === current.pointerId && + event.target === current.row + ) + abort(event); + }; + window.addEventListener('keydown', escape); + window.addEventListener('blur', abort); + window.addEventListener('pointermove', movePointer, { passive: false }); + window.addEventListener('pointerup', finishPointer); + window.addEventListener('pointercancel', abort); + window.addEventListener('lostpointercapture', lostCapture); + return () => { + window.removeEventListener('keydown', escape); + window.removeEventListener('blur', abort); + window.removeEventListener('pointermove', movePointer); + window.removeEventListener('pointerup', finishPointer); + window.removeEventListener('pointercancel', abort); + window.removeEventListener('lostpointercapture', lostCapture); + cancel(); + }; + }, [cancel, finishPointer, movePointer]); + useEffect(() => { + cancel(); + }, [cancel, disabled, order]); + + const save = async ( + next: SeriesDisclosureRole[], + role: SeriesDisclosureRole + ) => { + if (next.every((item, index) => item === order[index])) return; + setSaving(true); + try { + await onOrderChange(next); + setAnnouncement( + intl.formatMessage(messages.moved, { + label: items.get(role)?.props.label, + position: next.indexOf(role) + 1, + }) + ); + } catch { + setAnnouncement(intl.formatMessage(messages.failed)); + } finally { + setSaving(false); + } + }; + return ( +
    + {leading} + {(draft ?? order).map((role) => { + const child = items.get(role); + if (!child) return null; + return cloneElement(child, { + reorder: { + role, + descriptionId, + state: active?.role === role ? active.state : undefined, + handlers: { + onPointerDown: (event) => { + suppressClick.current = undefined; + if ( + disabled || + saving || + event.button !== 0 || + !event.isPrimary || + event.pointerType !== 'mouse' + ) + return; + cancel(); + const row = event.currentTarget.closest( + '.media-detail-disclosure-row' + ); + const control = event.currentTarget.closest( + '[data-disclosure-role]' + ); + if (!row || !control) return; + const bounds = control.getBoundingClientRect(); + // cloneNode copies rendered artwork/markup, never React/native + // listeners. Remove identifiers and make every copied focus stop inert. + const snapshot = control.cloneNode(true) as HTMLElement; + for (const element of [ + snapshot, + ...snapshot.querySelectorAll('*'), + ]) { + for (const attribute of [ + 'id', + 'aria-controls', + 'aria-describedby', + 'data-disclosure-role', + 'data-reorder-state', + 'data-reorder-insertion', + ]) + element.removeAttribute(attribute); + if ( + element.matches('button,a,input,select,textarea,[tabindex]') + ) + element.setAttribute('tabindex', '-1'); + } + const current: Gesture = { + role, + pointerId: event.pointerId, + x: event.clientX, + y: event.clientY, + ready: false, + moved: false, + button: event.currentTarget, + row, + width: bounds.width, + height: bounds.height, + offsetX: event.clientX - bounds.left, + offsetY: event.clientY - bounds.top, + original: [...order], + draft: [...order], + snapshot, + anchors: [ + ...row.querySelectorAll( + '[data-disclosure-role]' + ), + ].map((element) => ({ + role: element.dataset + .disclosureRole as SeriesDisclosureRole, + bounds: element.getBoundingClientRect(), + })), + commit: (next) => { + void save(next, role); + }, + }; + gesture.current = current; + // Capture only after hold, on the stable row rather than a moving + // control. Quick clicks retain their native button click target. + current.timer = setTimeout(() => { + if (gesture.current !== current) return; + current.ready = true; + row.setPointerCapture?.(current.pointerId); + setActive({ role, state: 'ready' }); + setAnnouncement( + intl.formatMessage(messages.ready, { + label: child.props.label, + }) + ); + }, DISCLOSURE_DRAG_HOLD_MS); + }, + onClickCapture: (event) => { + if (suppressClick.current === role) { + event.preventDefault(); + event.stopPropagation(); + suppressClick.current = undefined; + } + }, + onKeyDown: (event) => { + if ( + disabled || + saving || + !event.shiftKey || + !event.altKey || + !['ArrowLeft', 'ArrowRight'].includes(event.key) + ) + return; + event.preventDefault(); + cancel(); + const index = order.indexOf(role), + nextIndex = index + (event.key === 'ArrowLeft' ? -1 : 1); + if (nextIndex >= 0 && nextIndex < order.length) + void save( + insertDisclosureRole( + order, + role, + order[nextIndex], + event.key === 'ArrowRight' + ), + role + ); + }, + }, + }, + }); + })} + {ghost && + createPortal( + + ); +}; +export default ReorderableDisclosureRow; diff --git a/src/components/MediaDetails/SeasonEpisodeTree.tsx b/src/components/MediaDetails/SeasonEpisodeTree.tsx new file mode 100644 index 000000000..ff07e6d50 --- /dev/null +++ b/src/components/MediaDetails/SeasonEpisodeTree.tsx @@ -0,0 +1,514 @@ +import Button from '@app/components/Common/Button'; +import MediaServerIcon, { + getMediaServerName, +} from '@app/components/Common/MediaServerIcon'; +import SelectionCircle, { + SelectionCircleIndicator, +} from '@app/components/Common/SelectionCircle'; +import Tooltip from '@app/components/Common/Tooltip'; +import { + CheckCircleIcon, + ChevronDownIcon, + ChevronRightIcon, + MinusIcon, + NoSymbolIcon, + PlusIcon, + ServerStackIcon, + XCircleIcon, +} from '@heroicons/react/24/outline'; +import { CheckIcon } from '@heroicons/react/24/solid'; +import type { WatchStatusResponse } from '@server/models/WatchStatus'; +import { useId, useState, type ReactNode } from 'react'; +import { useIntl } from 'react-intl'; + +export interface TreeEpisode { + id: number; + episodeNumber: number; + name: string; + available: boolean; + // Request selection is independent of library availability. + selectable?: boolean; + watched?: boolean; + releaseDate?: string; +} + +export interface TreeSeason { + seasonNumber: number; + name: string; + episodeCount?: number; + episodes: TreeEpisode[]; +} + +export interface SeasonEpisodeTreeProps { + layout: 1 | 6; + seasons: TreeSeason[]; + selectedIds: number[]; + onSelectionChange: (ids: number[]) => void; + mediaServerType?: WatchStatusResponse['serverType']; + feedback?: ReactNode; + selectionPurpose?: 'playback' | 'request'; + disabled?: boolean; +} + +export const isTreeEpisodeSelectable = (episode: TreeEpisode) => + episode.selectable ?? episode.available; + +export const formatTreeNumber = (number: number) => + String(number).padStart(2, '0'); + +export const seasonSelection = (season: TreeSeason, selectedIds: number[]) => { + const available = season.episodes.filter(isTreeEpisodeSelectable); + const selected = available.filter((episode) => + selectedIds.includes(episode.id) + ); + return { + disabled: available.length === 0, + selected: available.length > 0 && selected.length === available.length, + partial: selected.length > 0 && selected.length < available.length, + count: selected.length, + }; +}; + +export const toggleSeasonSelection = ( + season: TreeSeason, + selectedIds: number[] +) => { + const ids = season.episodes + .filter(isTreeEpisodeSelectable) + .map((episode) => episode.id); + const allSelected = + ids.length > 0 && ids.every((id) => selectedIds.includes(id)); + return allSelected + ? selectedIds.filter((id) => !ids.includes(id)) + : [...new Set([...selectedIds, ...ids])]; +}; + +const layouts = { + 1: 'outline', + 6: 'amber-tree', +} as const; + +const SeasonEpisodeTree = ({ + layout, + seasons, + selectedIds, + onSelectionChange, + mediaServerType, + feedback, + selectionPurpose = 'playback', + disabled = false, +}: SeasonEpisodeTreeProps) => { + const intl = useIntl(); + const instanceId = useId(); + const [expanded, setExpanded] = useState([]); + const isExpanded = (season: TreeSeason) => + expanded.includes(season.seasonNumber); + const panelId = (season: TreeSeason) => + `${instanceId}-season-${season.seasonNumber}`; + const availableIds = new Set( + seasons.flatMap((season) => + season.episodes + .filter(isTreeEpisodeSelectable) + .map((episode) => episode.id) + ) + ); + const selectedCount = [...availableIds].filter((id) => + selectedIds.includes(id) + ).length; + const allSelected = + availableIds.size > 0 && selectedCount === availableIds.size; + const toggleAll = () => + onSelectionChange( + allSelected + ? selectedIds.filter((id) => !availableIds.has(id)) + : [...new Set([...selectedIds, ...availableIds])] + ); + const toggleExpanded = (season: TreeSeason) => { + setExpanded((current) => + current.includes(season.seasonNumber) + ? current.filter((number) => number !== season.seasonNumber) + : [...current, season.seasonNumber] + ); + }; + const toggleEpisode = (episode: TreeEpisode) => { + if (!isTreeEpisodeSelectable(episode)) return; + onSelectionChange( + selectedIds.includes(episode.id) + ? selectedIds.filter((id) => id !== episode.id) + : [...selectedIds, episode.id] + ); + }; + const selectionDescription = + selectionPurpose === 'request' ? 'requestable' : 'available'; + const formatReleaseDate = (releaseDate?: string) => { + if (!releaseDate) return '—'; + // Metadata dates are calendar dates, not local-time instants. Keep the day + // intact while formatting in the user's app locale. + const date = new Date(`${releaseDate}T12:00:00Z`); + if (Number.isNaN(date.getTime())) return '—'; + return intl.formatDate(date, { + weekday: 'short', + year: 'numeric', + month: 'short', + day: 'numeric', + timeZone: 'UTC', + }); + }; + + const seasonControls = (season: TreeSeason) => { + const state = seasonSelection(season, selectedIds); + const open = isExpanded(season); + const selection = ( + + + + onSelectionChange(toggleSeasonSelection(season, selectedIds)) + } + /> + + + ); + const episodeCount = season.episodeCount ?? season.episodes.length; + const countDescription = `${state.count} of ${episodeCount} episodes selected`; + const expanderIcon = + layout === 6 ? null : open ? ( + + ) : ( + + ); + return ( + <> + {selection} + + + ); + }; + + const episodeContent = (episode: TreeEpisode) => ( + <> + + + + + + + + + {episode.available ? ( + + ) : ( + + )} + + + + + {episode.watched === undefined ? ( + '?' + ) : episode.watched ? ( + + + + ); + + const columnHeadings = () => ( +
    + + + 0 && !allSelected} + disabled={disabled || availableIds.size === 0} + label={`${allSelected ? 'Deselect' : 'Select'} all ${selectionDescription} episodes`} + onClick={toggleAll} + /> + + + + + # + + + + Title + + + Release Date + + + + + + + + + {mediaServerType !== undefined ? ( + + ) : ( + '—' + )} + + +
    + ); + + const episodes = (season: TreeSeason) => ( + + ); + + return ( +
    +
    +
    +
    + + + +
    +
    +
    Selected:
    +
    + {formatTreeNumber(selectedCount)} +
    +
    +
    +
    + {columnHeadings()} + {feedback} +
    + {layout === 1 && ( +
      + {seasons.map((season) => ( +
    • +
      + {seasonControls(season)} +
      + {episodes(season)} +
    • + ))} +
    + )} + {layout !== 1 && ( + <> +
      + {seasons.map((season) => ( +
    • +
      + {seasonControls(season)} +
      + {episodes(season)} +
    • + ))} +
    + + )} +
    +
    +
    +
    + ); +}; + +export default SeasonEpisodeTree; diff --git a/src/components/MediaDetails/SeriesSeasonEpisodeBrowser.tsx b/src/components/MediaDetails/SeriesSeasonEpisodeBrowser.tsx index ea1ff8a31..62af1dbeb 100644 --- a/src/components/MediaDetails/SeriesSeasonEpisodeBrowser.tsx +++ b/src/components/MediaDetails/SeriesSeasonEpisodeBrowser.tsx @@ -1,51 +1,38 @@ -import LoadingSpinner from '@app/components/Common/LoadingSpinner'; -import MediaServerIcon, { - getMediaServerName, -} from '@app/components/Common/MediaServerIcon'; -import SelectionCircle, { - selectFromRow, - selectFromRowKey, -} from '@app/components/Common/SelectionCircle'; -import Tooltip from '@app/components/Common/Tooltip'; -import globalMessages from '@app/i18n/globalMessages'; -import defineMessages from '@app/utils/defineMessages'; +import PageErrorMessage, { + type MessageRetry, +} from '@app/components/Common/PageErrorMessage'; +import SeasonEpisodeTree, { + formatTreeNumber, +} from '@app/components/MediaDetails/SeasonEpisodeTree'; import { - CheckCircleIcon, - ServerStackIcon, - XCircleIcon, -} from '@heroicons/react/24/outline'; -import { CheckIcon } from '@heroicons/react/24/solid'; + buildSeriesTreeData, + selectedTreeEpisodeIds, + treeSelectionToPlaybackIds, +} from '@app/components/MediaDetails/seriesTreeData'; +import defineMessages from '@app/utils/defineMessages'; import type { PlaybackCatalogResponse } from '@server/models/Playback'; import type { SeasonWithEpisodes, TvDetails } from '@server/models/Tv'; import type { WatchStatusResponse } from '@server/models/WatchStatus'; -import { useEffect, useMemo, useState } from 'react'; +import axios from 'axios'; +import { useEffect, useMemo } from 'react'; import { useIntl } from 'react-intl'; import useSWR from 'swr'; const messages = defineMessages('components.MediaDetails.SeriesBrowser', { - season: 'Season', - episodes: 'Episodes', - episode: 'Episode', - title: 'Title', specials: 'Specials', seasonNumber: 'Season {number}', - episodeNumber: 'Episode {number}', untitled: 'Untitled', noSeasons: 'No Seasons Available', - selectSeason: 'Select a season to view its episodes', - loadError: 'Episodes could not be loaded. Try selecting the season again.', - availabilityLegend: - 'Bright green check: fully available. Dark green check: partially available. Red X: not available.', - selection: 'Select items to play', - selectSeasonEpisodes: 'Select every available episode in this season', - deselectSeasonEpisodes: 'Clear this season from the playback selection', - watched: 'Watched', - unwatched: 'Unwatched', - unwatchedCount: - '{count} unwatched {count, plural, one {episode} other {episodes}}', + loadError: 'Episodes Could Not Be Loaded', + loadErrorHint: 'Episode information could not be fetched, please try again.', + retryEpisodesTooltip: + 'Fetch the seasons and episodes again without changing your playback selection.', }); interface SeriesSeasonEpisodeBrowserProps { + contained?: boolean; + metadataRetry: MessageRetry; + onLoadingChange?: (loading: boolean) => void; tvId: number; seasons: TvDetails['seasons']; catalog?: PlaybackCatalogResponse; @@ -55,6 +42,9 @@ interface SeriesSeasonEpisodeBrowserProps { } const SeriesSeasonEpisodeBrowser = ({ + contained = false, + metadataRetry, + onLoadingChange, tvId, seasons, catalog, @@ -67,370 +57,91 @@ const SeriesSeasonEpisodeBrowser = ({ () => seasons.filter((season) => season.episodeCount > 0), [seasons] ); - const [activeSeason, setActiveSeason] = useState( - visibleSeasons.find((season) => season.seasonNumber > 0)?.seasonNumber ?? - visibleSeasons[0]?.seasonNumber ?? - -1 + const { data, error, isValidating, mutate } = useSWR( + visibleSeasons.length + ? [ + 'series-season-metadata', + tvId, + ...visibleSeasons.map((season) => season.seasonNumber), + ] + : null, + async () => + Promise.all( + visibleSeasons.map( + async (season) => + ( + await axios.get( + `/api/v1/tv/${tvId}/season/${season.seasonNumber}` + ) + ).data + ) + ), + { revalidateOnFocus: false, shouldRetryOnError: false } ); - useEffect(() => { - if ( - !visibleSeasons.some((season) => season.seasonNumber === activeSeason) - ) { - setActiveSeason( - visibleSeasons.find((season) => season.seasonNumber > 0) - ?.seasonNumber ?? - visibleSeasons[0]?.seasonNumber ?? - -1 - ); - } - }, [activeSeason, visibleSeasons]); + onLoadingChange?.(isValidating); + }, [isValidating, onLoadingChange]); + useEffect(() => () => onLoadingChange?.(false), [onLoadingChange]); - const { data, error } = useSWR( - activeSeason >= 0 ? `/api/v1/tv/${tvId}/season/${activeSeason}` : null - ); - const selection = useMemo(() => new Set(selectedItemIds), [selectedItemIds]); - const activeCatalogSeason = catalog?.groups.find( - (season) => season.index === activeSeason - ); - const allPlayableItemIds = useMemo( + const treeData = useMemo( () => - catalog?.groups.flatMap((group) => group.items.map((item) => item.id)) ?? - [], - [catalog] - ); - const allSeriesSelected = - allPlayableItemIds.length > 0 && - allPlayableItemIds.every((itemId) => selection.has(itemId)); - const activeItemIds = activeCatalogSeason?.items.map((item) => item.id) ?? []; - const allActiveSelected = - activeItemIds.length > 0 && - activeItemIds.every((itemId) => selection.has(itemId)); - const toggleItems = (itemIds: string[]) => { - if (itemIds.length === 0) return; - const allSelected = itemIds.every((itemId) => selection.has(itemId)); - const next = new Set(selection); - itemIds.forEach((itemId) => - allSelected ? next.delete(itemId) : next.add(itemId) - ); - onSelectionChange([...next]); - }; - const toggleSeason = (seasonNumber: number) => { - const group = catalog?.groups.find( - (season) => season.index === seasonNumber - ); - if (!group?.available || group.items.length === 0) { - return; - } - const itemIds = group.items.map((item) => item.id); - toggleItems(itemIds); - }; - const toggleEpisode = (itemId: string) => { - const next = new Set(selection); - if (next.has(itemId)) { - next.delete(itemId); - } else { - next.add(itemId); - } - onSelectionChange([...next]); - }; - - const AvailabilityHeading = () => ( - - - - - + buildSeriesTreeData( + visibleSeasons, + data ?? [], + catalog, + watchedStatus, + (seasonNumber) => + seasonNumber === 0 + ? intl.formatMessage(messages.specials) + : intl.formatMessage(messages.seasonNumber, { + number: formatTreeNumber(seasonNumber), + }), + intl.formatMessage(messages.untitled) + ), + [visibleSeasons, data, catalog, watchedStatus, intl] ); - const AvailabilityIcon = ({ - available, - partial = false, - }: { - available: boolean; - partial?: boolean; - }) => { - const label = intl.formatMessage( - available - ? partial - ? globalMessages.partiallyavailable - : globalMessages.available - : globalMessages.notavailable - ); - return ( - - - {available ? ( - - ) : ( - - )} - - - ); - }; - const WatchHeading = () => - watchedStatus && getMediaServerName(watchedStatus.serverType) ? ( - - - - - - ) : ( - - ); - const WatchCell = ({ - watched, - unwatchedCount, - incompleteLibrary = false, - }: { - watched: boolean; - unwatchedCount?: number; - incompleteLibrary?: boolean; - }) => { - if (!watchedStatus) { - return ; - } - if (watched && !unwatchedCount && !incompleteLibrary) { - return ( - - - - - ); - } - const label = - incompleteLibrary && watched && !unwatchedCount - ? 'All downloaded episodes watched; this season is partially available' - : unwatchedCount && watched - ? intl.formatMessage(messages.unwatchedCount, { - count: unwatchedCount, - }) - : intl.formatMessage(messages.unwatched); - return ( - - - {unwatchedCount && watched ? unwatchedCount : '–'} - - - ); - }; + const feedback = + visibleSeasons.length === 0 ? ( + + ) : error ? ( + mutate(), + tooltip: intl.formatMessage(messages.retryEpisodesTooltip), + busy: isValidating, + }} + /> + ) : null; return ( -
    -
    -
    - toggleItems(allPlayableItemIds)} - selected={allSeriesSelected} - label={intl.formatMessage(messages.selection)} - /> - - {intl.formatMessage(messages.season)} - - - {intl.formatMessage(messages.episodes)} - - - -
    -
    - {visibleSeasons.length === 0 && ( -

    - {intl.formatMessage(messages.noSeasons)} -

    - )} - {visibleSeasons.map((season) => { - const group = catalog?.groups.find( - (candidate) => candidate.index === season.seasonNumber - ); - const availableEpisodeCount = group?.items.length ?? 0; - const available = availableEpisodeCount > 0; - const partiallyAvailable = - availableEpisodeCount > 0 && - availableEpisodeCount < season.episodeCount; - const selectedCount = - group?.items.filter((item) => selection.has(item.id)).length ?? 0; - const allSelected = - !!group?.items.length && selectedCount === group.items.length; - const partiallySelected = selectedCount > 0 && !allSelected; - const watchSeason = watchedStatus?.seasons?.find( - (item) => item.seasonNumber === season.seasonNumber - ); - return ( -
    { - selectFromRow(event, () => { - setActiveSeason(season.seasonNumber); - if (available) toggleSeason(season.seasonNumber); - }); - }} - onKeyDown={(event) => { - selectFromRowKey(event, () => { - setActiveSeason(season.seasonNumber); - if (available) toggleSeason(season.seasonNumber); - }); - }} - > - { - setActiveSeason(season.seasonNumber); - toggleSeason(season.seasonNumber); - }} - selected={allSelected} - partial={partiallySelected} - label={intl.formatMessage( - allSelected - ? messages.deselectSeasonEpisodes - : messages.selectSeasonEpisodes - )} - /> - - {season.seasonNumber === 0 - ? intl.formatMessage(messages.specials) - : intl.formatMessage(messages.seasonNumber, { - number: season.seasonNumber, - })} - - - {season.episodeCount} - - - -
    - ); - })} -
    -
    - -
    -
    - toggleItems(activeItemIds)} - selected={allActiveSelected} - label={intl.formatMessage(messages.selection)} - /> - - {intl.formatMessage(messages.episode)} - - - {intl.formatMessage(messages.title)} - - - -
    -
    - {!data && !error && activeSeason >= 0 && ( -
    - -
    - )} - {activeSeason < 0 && ( -

    - {intl.formatMessage(messages.selectSeason)} -

    - )} - {error && ( -

    - {intl.formatMessage(messages.loadError)} -

    - )} - {data?.episodes.map((episode) => { - const playableItem = activeCatalogSeason?.items.find( - (item) => item.index === episode.episodeNumber - ); - const available = !!playableItem; - const selected = playableItem - ? selection.has(playableItem.id) - : false; - const watchEpisode = watchedStatus?.seasons - ?.find((season) => season.seasonNumber === activeSeason) - ?.episodes.find( - (item) => item.episodeNumber === episode.episodeNumber - ); - return ( -
    { - if (playableItem) - selectFromRow(event, () => toggleEpisode(playableItem.id)); - }} - onKeyDown={(event) => { - if (playableItem) - selectFromRowKey(event, () => - toggleEpisode(playableItem.id) - ); - }} - > - playableItem && toggleEpisode(playableItem.id)} - selected={selected} - label={intl.formatMessage(messages.selection)} - /> - - {intl.formatMessage(messages.episodeNumber, { - number: episode.episodeNumber, - })} - - - {episode.name || intl.formatMessage(messages.untitled)} - - - -
    - ); - })} -
    -
    +
    + + onSelectionChange( + treeSelectionToPlaybackIds(treeData.playbackIdsByEpisode, ids) + ) + } + feedback={feedback} + />
    ); }; diff --git a/src/components/MediaDetails/VideoRatings.tsx b/src/components/MediaDetails/VideoRatings.tsx new file mode 100644 index 000000000..264c4ec52 --- /dev/null +++ b/src/components/MediaDetails/VideoRatings.tsx @@ -0,0 +1,88 @@ +import CollectionRatings from '@app/components/CollectionDetails/CollectionRatings'; +import { getCollectionMemberRatings } from '@app/utils/collectionRatings'; +import defineMessages from '@app/utils/defineMessages'; +import { getEffectiveVideoRatings } from '@app/utils/videoRatings'; +import type { RatingResponse } from '@server/api/ratings'; +import { useIntl } from 'react-intl'; + +interface VideoRatingsProps { + mediaType: 'movie' | 'tv'; + id: number; + voteAverage?: number; + voteCount?: number; + ratings?: RatingResponse; + loading?: boolean; +} + +const messages = defineMessages('components.VideoRatings', { + imdbVotes: 'IMDb user score – votes: {formattedCount}', +}); + +export const videoRatingSources = [ + 'critics', + 'audience', + 'imdb', + 'metacritic', + 'trakt', + 'tmdb', +] as const; + +// Keep the same provider slots even when a service has no score for this title. +export const getVideoRatingItems = ({ + mediaType, + id, + voteAverage = 0, + voteCount = 0, + ratings, +}: VideoRatingsProps) => { + const effective = getEffectiveVideoRatings(ratings); + const items = getCollectionMemberRatings( + { id, voteAverage, voteCount }, + ratings + ); + return videoRatingSources.map((source) => { + const item = items.find((rating) => rating.source === source)!; + switch (source) { + case 'critics': + return { ...item, value: effective.rtCriticsScore }; + case 'audience': + return { ...item, value: effective.rtAudienceScore }; + case 'imdb': + return { ...item, value: effective.imdbScore, href: effective.imdbUrl }; + case 'tmdb': + return { + ...item, + href: `https://www.themoviedb.org/${mediaType}/${id}`, + }; + default: + return item; + } + }); +}; + +const VideoRatings = (props: VideoRatingsProps) => { + const intl = useIntl(); + const effective = getEffectiveVideoRatings(props.ratings); + return ( + + ); +}; + +export default VideoRatings; diff --git a/src/components/MediaDetails/disclosureLabels.test.mjs b/src/components/MediaDetails/disclosureLabels.test.mjs index 1118729c8..112cbd28f 100644 --- a/src/components/MediaDetails/disclosureLabels.test.mjs +++ b/src/components/MediaDetails/disclosureLabels.test.mjs @@ -1,7 +1,125 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; import test from 'node:test'; +const ts = createRequire(import.meta.url)('typescript'); +const seriesFile = '../TvDetails/SeriesDetailsLayout.tsx'; +const seriesRoles = [ + 'overview', + 'cast', + 'crew', + 'subjectTags', + 'details', + 'mediaServer', +]; + +// The accepted Series contract is saved role ordering, not Details always last. +// Check real JSX ownership instead of splitting a retired literal div wrapper. +const verifySeriesOrder = (source) => { + const ast = ts.createSourceFile( + 'Series.tsx', + source, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX + ); + const rows = []; + const panels = []; + const visit = (node) => { + if (ts.isJsxElement(node)) { + const name = node.openingElement.tagName.getText(ast); + if (name === 'ReorderableDisclosureRow') rows.push(node); + if (name === 'OrderedDisclosurePanels') panels.push(node); + } + ts.forEachChild(node, visit); + }; + visit(ast); + assert.equal(rows.length, 1, 'Series controls need one shared reorder row'); + assert.equal( + panels.length, + 1, + 'Series panels need one committed-order owner' + ); + const attribute = (element, name) => + element.attributes.properties.find( + (property) => ts.isJsxAttribute(property) && property.name.text === name + )?.initializer; + const expression = (element, name) => { + const value = attribute(element, name); + return value && ts.isJsxExpression(value) ? value.expression : undefined; + }; + assert.equal( + expression(rows[0].openingElement, 'order')?.getText(ast), + 'disclosureOrder', + 'Series controls must use saved disclosure order' + ); + assert.equal( + expression(panels[0].openingElement, 'order')?.getText(ast), + 'disclosureOrder', + 'Series controls and panels must share committed order' + ); + assert.equal( + expression(rows[0].openingElement, 'onOrderChange')?.getText(ast), + 'setDisclosureOrder', + 'Series order must retain its persistence callback' + ); + assert.equal( + expression(rows[0].openingElement, 'disabled')?.getText(ast), + '!canReorder', + 'Series reorder must retain its permission guard' + ); + const leading = expression(rows[0].openingElement, 'leading'); + assert.ok( + leading && + ts.isJsxSelfClosingElement(leading) && + leading.tagName.getText(ast) === 'CollectionNavigation', + 'Collection navigation must remain outside reorderable roles' + ); + assert.equal( + attribute(leading, 'kind')?.text, + 'tv', + 'Series collection navigation must retain TV context' + ); + const buttons = []; + const collectButtons = (node) => { + if ( + ts.isJsxSelfClosingElement(node) && + node.tagName.getText(ast) === 'DetailDisclosureButton' + ) + buttons.push(node); + ts.forEachChild(node, collectButtons); + }; + rows[0].children.forEach(collectButtons); + assert.deepEqual( + buttons.map((button) => attribute(button, 'key')?.text), + seriesRoles, + 'Series controls must retain all unique stable role keys' + ); + assert.deepEqual( + buttons.map((button) => expression(button, 'label')?.getText(ast)), + [ + 'intl.formatMessage(messages.overview)', + 'intl.formatMessage(messages.viewCast)', + 'intl.formatMessage(messages.viewCrew)', + 'intl.formatMessage(messages.subjectTags)', + 'intl.formatMessage(messages.seriesDetails)', + 'intl.formatMessage(messages.mediaServer)', + ], + 'Series labels must remain attached to their actual roles' + ); + const fragments = panels[0].children.filter( + (node) => + ts.isJsxElement(node) && + node.openingElement.tagName.getText(ast) === 'Fragment' + ); + assert.deepEqual( + fragments.map((node) => attribute(node.openingElement, 'key')?.text), + seriesRoles, + 'Series panels must retain the same stable role keys' + ); +}; + const locale = JSON.parse( readFileSync(new URL('../../i18n/locale/en.json', import.meta.url), 'utf8') ); @@ -19,7 +137,12 @@ const layouts = [ [ '../TvDetails/SeriesDetailsLayout.tsx', 'components.TvDetails.Layout', - { viewCast: 'Cast', viewCrew: 'Crew', seriesDetails: 'Details' }, + { + overview: 'Overview', + viewCast: 'Cast', + viewCrew: 'Crew', + seriesDetails: 'Details', + }, ], [ '../MusicDetails/MusicDetailsLayout.tsx', @@ -69,28 +192,61 @@ const disclosureOrders = [ '../MusicDetails/MusicDetailsLayout.tsx', ['viewArtists', 'subjectTags', 'albumDetails'], ], - ['../BookDetails/BookDetailsLayout.tsx', ['genres', 'bookDetails']], + // The existing Book row now exposes Collection and Keywords; Details remains + // last. Preserve that order rather than freezing the retired Genres label. + [ + '../BookDetails/BookDetailsLayout.tsx', + ['collection', 'keywords', 'bookDetails'], + ], ]; for (const [file, order] of disclosureOrders) { - test(`${file} keeps Details last in the requested disclosure order`, () => { - const source = readFileSync(new URL(file, import.meta.url), 'utf8'); - const row = source - .split('
    ')[1] - .split('
    ')[0]; - const labels = [ - ...row.matchAll(/label={intl.formatMessage\(messages\.(\w+)\)}/g), - ].map((match) => match[1]); - assert.deepEqual(labels, order); - if (file.includes('MusicDetails') || file.includes('TvDetails')) { - assert.ok( - row.indexOf(' { + const source = readFileSync(new URL(file, import.meta.url), 'utf8'); + if (file === seriesFile) { + verifySeriesOrder(source); + return; + } + const row = source + .split('
    ')[1] + .split('
    ')[0]; + const labels = [ + ...row.matchAll(/label={intl.formatMessage\(messages\.(\w+)\)}/g), + ].map((match) => match[1]); + assert.deepEqual(labels, order); + if (file.includes('MusicDetails') || file.includes('TvDetails')) { + assert.ok( + row.indexOf(' { + const source = readFileSync(new URL(seriesFile, import.meta.url), 'utf8'); + const split = source.replace( + '', + '' + ); + assert.notEqual(split, source, 'split-order mutation must apply'); + assert.throws( + () => verifySeriesOrder(split), + /Series controls and panels must share committed order/ + ); + const missingRole = source.replace('key="details"', 'key="cast"'); + assert.notEqual(missingRole, source, 'duplicate-role mutation must apply'); + assert.throws( + () => verifySeriesOrder(missingRole), + /Series controls must retain all unique stable role keys/ + ); +}); + test('Request Discography alone opts into the shared right-aligned catalog action style', () => { const music = readFileSync( new URL('../MusicDetails/index.tsx', import.meta.url), diff --git a/src/components/MediaDetails/seriesTreeData.ts b/src/components/MediaDetails/seriesTreeData.ts new file mode 100644 index 000000000..233277b43 --- /dev/null +++ b/src/components/MediaDetails/seriesTreeData.ts @@ -0,0 +1,80 @@ +import type { TreeSeason } from '@app/components/MediaDetails/SeasonEpisodeTree'; +import type { PlaybackCatalogResponse } from '@server/models/Playback'; +import type { SeasonWithEpisodes, TvDetails } from '@server/models/Tv'; +import type { WatchStatusResponse } from '@server/models/WatchStatus'; + +// Metadata IDs identify rows; provider IDs identify the exact playable files. +// Never send a metadata ID to a media-server playback command. +export interface SeriesTreeData { + seasons: TreeSeason[]; + playbackIdsByEpisode: Map; +} + +export const buildSeriesTreeData = ( + seasons: TvDetails['seasons'], + metadata: SeasonWithEpisodes[], + catalog: PlaybackCatalogResponse | undefined, + watchedStatus: WatchStatusResponse | undefined, + seasonName: (seasonNumber: number) => string, + untitled: string +): SeriesTreeData => { + const playbackIdsByEpisode = new Map(); + const treeSeasons = seasons.map((season) => { + const group = catalog?.groups.find( + (candidate) => candidate.index === season.seasonNumber + ); + const watchedSeason = watchedStatus?.seasons?.find( + (candidate) => candidate.seasonNumber === season.seasonNumber + ); + const episodes = + metadata.find( + (candidate) => candidate.seasonNumber === season.seasonNumber + )?.episodes ?? []; + return { + seasonNumber: season.seasonNumber, + name: seasonName(season.seasonNumber), + episodeCount: season.episodeCount, + episodes: episodes.map((episode) => { + const playableItem = group?.items.find( + (candidate) => candidate.index === episode.episodeNumber + ); + if (playableItem) playbackIdsByEpisode.set(episode.id, playableItem.id); + return { + id: episode.id, + episodeNumber: episode.episodeNumber, + name: episode.name || untitled, + releaseDate: episode.airDate || undefined, + available: Boolean(playableItem), + watched: watchedSeason?.episodes.find( + (candidate) => candidate.episodeNumber === episode.episodeNumber + )?.watched, + }; + }), + }; + }); + return { seasons: treeSeasons, playbackIdsByEpisode }; +}; + +export const selectedTreeEpisodeIds = ( + playbackIdsByEpisode: Map, + selectedItemIds: string[] +) => { + const selected = new Set(selectedItemIds); + return [...playbackIdsByEpisode] + .filter(([, playbackId]) => selected.has(playbackId)) + .map(([episodeId]) => episodeId); +}; + +export const treeSelectionToPlaybackIds = ( + playbackIdsByEpisode: Map, + selectedEpisodeIds: number[] +) => { + const selected = new Set(selectedEpisodeIds); + return [ + ...new Set( + [...playbackIdsByEpisode] + .filter(([episodeId]) => selected.has(episodeId)) + .map(([, playbackId]) => playbackId) + ), + ]; +}; diff --git a/src/components/MediaDetails/subjectTagStyle.ts b/src/components/MediaDetails/subjectTagStyle.ts index 1452454ad..95514432f 100644 --- a/src/components/MediaDetails/subjectTagStyle.ts +++ b/src/components/MediaDetails/subjectTagStyle.ts @@ -13,5 +13,8 @@ const subjectTagTones = [ 'purple', ] as const; +export const subjectTagTone = (index: number) => + subjectTagTones[index % subjectTagTones.length]; + export const subjectTagClassName = (index: number): string => - `compact-control subject-tag subject-tag-${subjectTagTones[index % subjectTagTones.length]}`; + `compact-control subject-tag subject-tag-${subjectTagTone(index)}`; diff --git a/src/components/MediaSlider/ShowMoreCard/index.tsx b/src/components/MediaSlider/ShowMoreCard/index.tsx index 21c9b08e7..71fc1f382 100644 --- a/src/components/MediaSlider/ShowMoreCard/index.tsx +++ b/src/components/MediaSlider/ShowMoreCard/index.tsx @@ -4,7 +4,7 @@ import defineMessages from '@app/utils/defineMessages'; import { getTmdbPosterImageUrl } from '@app/utils/imageCache'; import { ArrowRightCircleIcon } from '@heroicons/react/24/solid'; import Link from 'next/link'; -import { memo, useMemo } from 'react'; +import { memo, useMemo, useState } from 'react'; import { useInView } from 'react-intersection-observer'; import { useIntl } from 'react-intl'; @@ -34,6 +34,7 @@ const getImageProps = (poster: string) => { const ShowMoreCard = memo(({ url, posters }: ShowMoreCardProps) => { const intl = useIntl(); + const [isHovered, setHovered] = useState(false); const { ref, inView } = useInView({ triggerOnce: true, }); @@ -54,68 +55,70 @@ const ShowMoreCard = memo(({ url, posters }: ShowMoreCardProps) => { setHovered(true)} + onMouseLeave={() => setHovered(false)} + onFocus={() => setHovered(true)} + onBlur={() => setHovered(false)} role="link" tabIndex={0} > -
    -
    -
    -
    - {imageProps[0] && ( -
    - -
    - )} - {imageProps[1] && ( -
    - -
    - )} - {imageProps[2] && ( -
    - -
    - )} - {imageProps[3] && ( -
    - -
    - )} -
    -
    - -
    - {intl.formatMessage(messages.seemore)} +
    +
    +
    + {imageProps[0] && ( +
    + +
    + )} + {imageProps[1] && ( +
    + +
    + )} + {imageProps[2] && ( +
    +
    + )} + {imageProps[3] && ( +
    + +
    + )} +
    +
    + +
    + {intl.formatMessage(messages.seemore)}
    diff --git a/src/components/MediaSlider/index.tsx b/src/components/MediaSlider/index.tsx index b3e797634..df855d9ef 100644 --- a/src/components/MediaSlider/index.tsx +++ b/src/components/MediaSlider/index.tsx @@ -72,6 +72,7 @@ interface MediaSliderProps { onNewTitles?: (titleCount: number) => void; randomizeOrder?: boolean; prioritizeFirstRow?: boolean; + posterTitleWeight?: 'regular'; } type SliderTitle = @@ -95,6 +96,7 @@ const MediaSlider = ({ onNewTitles, randomizeOrder = false, prioritizeFirstRow = false, + posterTitleWeight, }: MediaSliderProps) => { const settings = useSettings(); const { visibility } = useCardTextVisibility(); @@ -425,6 +427,7 @@ const MediaSlider = ({ case 'movie': return ( -
    - {linkUrl ? ( - - {title} - - ) : ( -
    - {title} -
    - )} - {visibleMediaTypes.length > 0 && ( - - )} - {randomizeOrder && ( - - - - )} -
    + {linkUrl ? ( + + {title} + + ) : ( +
    + {title} +
    + )} + {visibleMediaTypes.length > 0 && ( + + )} + {randomizeOrder && ( + + + + )} + + } sliderKey={sliderKey} isLoading={snapshotHydrated && shouldLoad && !data && !error} isEmpty={!!data && hasReachedEnd && !renderableTitles.length} diff --git a/src/components/MovieDetails/MovieDetailsLayout.tsx b/src/components/MovieDetails/MovieDetailsLayout.tsx index 96476f20b..c62fd5dbc 100644 --- a/src/components/MovieDetails/MovieDetailsLayout.tsx +++ b/src/components/MovieDetails/MovieDetailsLayout.tsx @@ -1,26 +1,16 @@ -import RTAudFresh from '@app/assets/rt_aud_fresh.svg'; -import RTAudRotten from '@app/assets/rt_aud_rotten.svg'; -import RTFresh from '@app/assets/rt_fresh.svg'; -import RTRotten from '@app/assets/rt_rotten.svg'; -import ImdbLogo from '@app/assets/services/imdb.svg'; -import TmdbLogo from '@app/assets/tmdb_logo.svg'; import CollectionSummaryCard from '@app/components/CollectionDetails/CollectionSummaryCard'; -import Tooltip from '@app/components/Common/Tooltip'; import DetailDisclosureButton from '@app/components/MediaDetails/DetailDisclosureButton'; import ExpandableCreditList from '@app/components/MediaDetails/ExpandableCreditList'; -import MdblistRatingBadges from '@app/components/MediaDetails/MdblistRatingBadges'; import MediaDetailArtwork from '@app/components/MediaDetails/MediaDetailArtwork'; import MediaQualitySelect from '@app/components/MediaDetails/MediaQualitySelect'; import MetadataAttribution from '@app/components/MediaDetails/MetadataAttribution'; import MovieSummaryCard from '@app/components/MediaDetails/MovieSummaryCard'; +import VideoRatings from '@app/components/MediaDetails/VideoRatings'; import { subjectTagClassName } from '@app/components/MediaDetails/subjectTagStyle'; import MediaSlider from '@app/components/MediaSlider'; import useDetailDisclosurePins from '@app/hooks/useDetailDisclosurePins'; -import useLocale from '@app/hooks/useLocale'; import useWatchStatus from '@app/hooks/useWatchStatus'; import defineMessages from '@app/utils/defineMessages'; -import { getSafeHref } from '@app/utils/safeUrl'; -import { getEffectiveVideoRatings } from '@app/utils/videoRatings'; import type { RatingResponse } from '@server/api/ratings'; import { MediaStatus } from '@server/constants/media'; import type { MovieDetails } from '@server/models/Movie'; @@ -46,8 +36,8 @@ const messages = defineMessages('components.MovieDetails.Layout', { subjectTags: 'Subject Tags', fullCastList: 'Full Cast List', fullCrewList: 'Full Crew List', - noCast: 'No cast information available', - noCrew: 'No crew information available', + noCast: 'No Cast Information Available', + noCrew: 'No Crew Information Available', noTags: 'No subject tags available', movieDetails: 'Details', status: 'Status', @@ -61,11 +51,6 @@ const messages = defineMessages('components.MovieDetails.Layout', { minutes: '{minutes} minutes', recommendations: 'Recommendations', similar: 'Similar Titles', - rtCriticsScore: 'Rotten Tomatoes Tomatometer', - rtAudienceScore: 'Rotten Tomatoes Audience Score', - imdbUserScore: 'IMDB User Score – votes: {formattedCount}', - imdbScore: 'IMDb user score', - tmdbUserScore: 'TMDB User Score', theatrical: 'Theatrical', digital: 'Digital', physical: 'Physical', @@ -110,20 +95,12 @@ const MovieDetailsLayout = ({ playbackActions, }: MovieDetailsLayoutProps) => { const intl = useIntl(); - const { locale } = useLocale(); - const effectiveRatings = getEffectiveVideoRatings(ratingData); const { data: watchedStatus } = useWatchStatus( 'movie', data.id, Boolean(data.mediaInfo) ); const { pins, togglePinned } = useDetailDisclosurePins('movie'); - const [showRatingsReview, setShowRatingsReview] = useState(false); - useEffect(() => { - setShowRatingsReview( - new URLSearchParams(window.location.search).has('ratingsReview') - ); - }, []); const [showDetails, setShowDetails] = useState(false); useEffect(() => { setShowDetails(pins.details); @@ -370,7 +347,10 @@ const MovieDetailsLayout = ({

    {intl.formatMessage(messages.movieDetails)}

    -
    +
    {intl.formatMessage(messages.status)}: @@ -473,7 +453,6 @@ const MovieDetailsLayout = ({
    )} - +{' '}
    {playbackActions?.(selectedQuality === '4k')} diff --git a/src/components/MovieDetails/collectionDisclosure.test.mjs b/src/components/MovieDetails/collectionDisclosure.test.mjs index 22164356c..5440a251d 100644 --- a/src/components/MovieDetails/collectionDisclosure.test.mjs +++ b/src/components/MovieDetails/collectionDisclosure.test.mjs @@ -1,6 +1,8 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import test from 'node:test'; +import postcss from 'postcss'; +import { styleContract } from '../../styles/cssContract.mjs'; const source = readFileSync( new URL('./MovieDetailsLayout.tsx', import.meta.url), @@ -51,31 +53,95 @@ test('collection page and movie summary share the same table placement', () => { } }); -test('collection table expands its overview before the final two genre rows', () => { - const css = readFileSync( - new URL('../../styles/globals.css', import.meta.url), - 'utf8' +const collectionCss = () => + readFileSync(new URL('../../styles/globals.css', import.meta.url), 'utf8'); +const assertCollectionLayout = (css) => { + const contract = styleContract(css); + const tableRules = contract.rulesFor('.collection-summary-table'); + const rowRules = tableRules.filter((rule) => + rule.nodes.some( + (node) => node.type === 'decl' && node.prop === 'grid-template-rows' + ) ); - assert.match( - css, - /grid-template-rows:\s*minmax\(calc\(3 \* var\(--detail-row-height\)\), auto\)\s*repeat\(2, minmax\(var\(--detail-row-height\), auto\)\)/ + assert.equal(rowRules.length, 2); + assert.equal(rowRules[0].parent.name, 'layer'); + assert.equal(rowRules[1].parent.name, 'media'); + assert.equal(rowRules[1].parent.params, '(min-width: 720px)'); + assert.ok( + contract.applies('.collection-summary-overview-value').has('row-start-1') ); - assert.match(css, /\.collection-summary-genres-value\s*\{[^}]*line-clamp-2/); - assert.match( - css, - /\.collection-summary-genres-value\s*\{\s*grid-column: 2 \/ 5;/ + assert.ok( + contract.applies('.collection-summary-genres-value').has('line-clamp-2') ); - assert.match( - css, - /\.collection-summary-size-label\s*\{\s*grid-column: 5;\s*grid-row: 2;/ + assert.ok( + contract.applies('.collection-summary-genres-value').has('row-start-3') ); - assert.match( - css, - /\.collection-summary-size-value\s*\{\s*grid-column: 6;\s*grid-row: 2;/ + assert.ok( + contract.applies('.collection-summary-genres-value').has('row-span-2') ); assert.match( - css, - /\.collection-summary-table::after\s*\{[^}]*grid-row: 2 \/ 4;/ + contract.declaration('.collection-summary-table', 'grid-template-rows'), + /minmax\(calc\(3 \* var\(--detail-row-height\)\), auto\)\s*repeat\(2, minmax\(var\(--detail-row-height\), auto\)\)/ + ); + // The size pair owns its inner two-column grid; the wide table owns three tracks. + assert.ok( + contract + .applies('.collection-summary-size') + .has('grid-cols-[max-content_minmax(0,1fr)]') + ); + assert.ok( + contract.applies('.collection-summary-size-label').has('col-start-1') + ); + assert.ok( + contract.applies('.collection-summary-size-value').has('col-start-2') + ); + assert.equal( + contract.declaration('.collection-summary-genres-value', 'grid-column'), + '2' + ); + assert.equal( + contract.declaration('.collection-summary-genres-value', 'grid-row'), + '2 / 4' + ); + assert.equal( + contract.declaration('.collection-summary-size', 'grid-column'), + '3' + ); + assert.equal( + contract.declaration('.collection-summary-size', 'grid-row'), + '2 / 4' + ); + assert.equal( + contract.declaration('.collection-summary-table::after', 'grid-column'), + '3' + ); + assert.equal( + contract.declaration('.collection-summary-table::after', 'grid-row'), + '2 / 4' + ); + for (const file of [ + '../CollectionDetails/index.tsx', + '../CollectionDetails/CollectionSummaryCard.tsx', + ]) { + const consumer = readFileSync(new URL(file, import.meta.url), 'utf8'); + assert.match( + consumer, + /
    \s*
    / + ); + } +}; + +test('collection table expands its overview and shares responsive genre and size owners', () => { + assertCollectionLayout(collectionCss()); +}); + +test('collection placement check rejects a size column detached from the divider', () => { + assert.throws( + () => + assertCollectionLayout( + collectionCss() + '\n.collection-summary-size { grid-column: 4; }' + ), + assert.AssertionError ); }); @@ -88,24 +154,22 @@ test('black glowing divider overrides are limited to Blackout', () => { new URL('../../styles/globals.css', import.meta.url), 'utf8' ); - const rules = [...css.matchAll(/([^{}]+)\{([^{}]*)\}/g)]; - const theme = css.match( - /\[data-theme-palette='blackout'\]\s*\{([^}]+)\}/ - )?.[1]; - assert.ok(theme); - assert.match( - theme, - /--theme-detail-divider-shadow:\s*0 0 4px 0 rgb\(255 255 255 \/ 0\.8\);/ - ); - assert.doesNotMatch(theme, /0 0 0 1px rgb\(255 255 255\)/); - const glowing = rules.filter(([, , body]) => - body.includes('box-shadow: var(--theme-detail-divider-shadow)') + const contract = styleContract(css); + const palette = "[data-theme-palette='seerr']"; + assert.equal( + contract.declaration(palette, '--theme-detail-divider-shadow'), + '0 0 4px 0 rgb(255 255 255 / 0.8)' ); + const glowing = []; + postcss.parse(css).walkDecls('box-shadow', (decl) => { + if (decl.value === 'var(--theme-detail-divider-shadow)') + glowing.push(decl.parent); + }); assert.equal(glowing.length, 2); - for (const [, selectors, body] of glowing) { - for (const selector of selectors.split(',')) { - assert.ok(selector.trim().startsWith("[data-theme-palette='blackout']")); + for (const rule of glowing) { + for (const selector of rule.selectors) { + assert.ok(selector.trim().startsWith(palette + ' ')); + assert.equal(contract.declaration(selector, 'background-color'), 'black'); } - assert.match(body, /background-color: black;/); } }); diff --git a/src/components/PersonCard/index.tsx b/src/components/PersonCard/index.tsx index 968b3eafb..2ed7e4067 100644 --- a/src/components/PersonCard/index.tsx +++ b/src/components/PersonCard/index.tsx @@ -17,7 +17,6 @@ const PersonCard = ({ name, subName, profilePath, - canExpand = false, }: PersonCardProps) => { const [isHovered, setHovered] = useState(false); @@ -25,7 +24,8 @@ const PersonCard = ({ { setHovered(true); }} @@ -39,16 +39,13 @@ const PersonCard = ({ tabIndex={0} >
    -
    -
    +
    +
    {profilePath ? (
    @@ -71,7 +68,12 @@ const PersonCard = ({ )}
    -
    {name}
    +
    + {name} +
    {subName && (
    { const cast = (sortedCast ?? []).length > 0 && ( <>
    -
    +
    {intl.formatMessage(messages.appearsin)}
    @@ -202,7 +202,7 @@ const PersonDetails = () => { const crew = (sortedCrew ?? []).length > 0 && ( <>
    -
    +
    {intl.formatMessage(messages.crewmember)}
    diff --git a/src/components/RegionSelector/index.tsx b/src/components/RegionSelector/index.tsx index 4c099def1..a26cd0e2e 100644 --- a/src/components/RegionSelector/index.tsx +++ b/src/components/RegionSelector/index.tsx @@ -1,3 +1,4 @@ +import { useSearchActivityReporter } from '@app/hooks/useSearchActivity'; import useSettings from '@app/hooks/useSettings'; import defineMessages from '@app/utils/defineMessages'; import { @@ -11,7 +12,7 @@ import { CheckIcon, ChevronDownIcon } from '@heroicons/react/24/solid'; import type { Region } from '@server/lib/settings'; import { countries } from 'country-flag-icons'; import 'country-flag-icons/3x2/flags.css'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useId, useMemo, useState } from 'react'; import { useIntl } from 'react-intl'; import useSWR from 'swr'; @@ -43,9 +44,14 @@ const RegionSelector = ({ }: RegionSelectorProps) => { const { currentSettings } = useSettings(); const intl = useIntl(); - const { data: regions } = useSWR( + const { data: regions, isLoading } = useSWR( watchProviders ? '/api/v1/watchproviders/regions' : '/api/v1/regions' ); + const activityId = useId(); + useSearchActivityReporter( + compact && isLoading, + `region-selector:${activityId}` + ); const [selectedRegion, setSelectedRegion] = useState(null); const allRegion: Region = useMemo( @@ -97,20 +103,38 @@ const RegionSelector = ({ onChange?.(name, region?.iso_3166_1 ?? ''); }; + const optionClass = (active: boolean) => + compact + ? `app-filter-select-option ${active ? 'app-filter-select-option-active' : ''}` + : `${active ? 'bg-indigo-600 text-white' : 'text-gray-300'} relative flex cursor-default items-center pr-4 pl-8 select-none py-2`; + const valueClass = (selected: boolean) => + compact + ? undefined + : `${selected ? 'font-semibold' : 'font-normal'} block truncate`; + return ( -
    +
    {({ open }) => ( -
    +
    {((selectedRegion && countries.includes(selectedRegion?.iso_3166_1)) || @@ -118,7 +142,14 @@ const RegionSelector = ({ !selectedRegion && regionValue && countries.includes(regionValue))) && ( - + )} - + {selectedRegion && selectedRegion.iso_3166_1 !== 'all' ? regionName(selectedRegion.iso_3166_1) : isUserSetting && selectedRegion?.iso_3166_1 !== 'all' @@ -137,45 +171,64 @@ const RegionSelector = ({ }) : intl.formatMessage(messages.regionDefault)} - - - + {compact ? ( + + ) : ( + + + + )} {isUserSetting && ( {({ selected, active }) => (
    - + {intl.formatMessage(messages.regionServerDefault, { region: regionValue @@ -185,11 +238,15 @@ const RegionSelector = ({ {selected && ( - + )}
    @@ -200,24 +257,35 @@ const RegionSelector = ({ {({ selected, active }) => (
    {intl.formatMessage(messages.regionDefault)} {selected && ( - + )}
    @@ -228,33 +296,41 @@ const RegionSelector = ({ {({ selected, active }) => (
    - + {regionName(region.iso_3166_1)} {selected && ( - + )}
    diff --git a/src/components/RequestButton/RequestButton.entry.test.tsx b/src/components/RequestButton/RequestButton.entry.test.tsx new file mode 100644 index 000000000..0081c6002 --- /dev/null +++ b/src/components/RequestButton/RequestButton.entry.test.tsx @@ -0,0 +1,209 @@ +import { MediaRequestStatus, MediaStatus } from '@server/constants/media'; +import type Media from '@server/entity/Media'; +import { Permission } from '@server/lib/permissions'; +import axios from 'axios'; +import { JSDOM } from 'jsdom'; +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { IntlProvider } from 'react-intl'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import RequestButton from './index'; + +const state = vi.hoisted(() => ({ + permissions: 0, + services: [] as { is4k: boolean }[], +})); +vi.mock('axios', () => ({ default: { post: vi.fn() } })); +vi.mock('swr', () => ({ + default: () => ({ data: state.services }), + mutate: vi.fn(), +})); +vi.mock('@app/hooks/useSettings', () => ({ + default: () => ({ currentSettings: { series4kEnabled: true } }), +})); +vi.mock('@app/hooks/useToasts', () => ({ + default: () => ({ addToast: vi.fn() }), +})); +vi.mock('@app/hooks/useUser', async () => { + const { Permission, hasPermission } = await import('@server/lib/permissions'); + return { + Permission, + useUser: () => ({ + user: { id: 7, permissions: state.permissions }, + hasPermission: ( + permissions: Parameters[0], + options?: Parameters[2] + ) => hasPermission(permissions, state.permissions, options), + }), + }; +}); +vi.mock('next/dynamic', () => ({ + default: () => + function RequestScreen({ + is4k, + show4kSelector, + editRequest, + }: { + is4k?: boolean; + show4kSelector?: boolean; + editRequest?: { id: number }; + }) { + return ( +
    + ); + }, +})); +vi.mock('@app/components/Common/Button', () => ({ + default: ({ + children, + onClick, + disabled, + buttonType, + }: { + children: React.ReactNode; + onClick?: () => void; + disabled?: boolean; + buttonType?: string; + }) => ( + + ), +})); +vi.mock('@app/components/Common/Tooltip', () => ({ + default: ({ children }: { children: React.ReactNode }) => children, +})); + +let dom: JSDOM; +let root: ReturnType; +let host: HTMLDivElement; +beforeEach(() => { + state.permissions = Permission.REQUEST_TV | Permission.REQUEST_4K_TV; + state.services = [{ is4k: false }, { is4k: true }]; + vi.clearAllMocks(); + dom = new JSDOM(''); + vi.stubGlobal('React', React); + vi.stubGlobal('window', dom.window); + vi.stubGlobal('document', dom.window.document); + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); +}); +afterEach(async () => { + await act(async () => root.unmount()); + dom.window.close(); + vi.unstubAllGlobals(); +}); +const render = async ( + props: Partial> = {} +) => { + await act(async () => + root.render( + + + + ) + ); +}; +const clickRequest = async () => { + const button = Array.from(host.querySelectorAll('button')).find( + (node) => node.textContent === 'Request' + ); + expect(button).toBeDefined(); + await act(async () => button!.click()); + return button!; +}; + +it('opens the request screen from one shared action, leaving quality selection inside', async () => { + await render(); + expect(host.querySelectorAll('button')).toHaveLength(1); + expect( + host.querySelector('[data-testid="format-request-control"]') + ).toBeNull(); + const button = await clickRequest(); + expect(button.dataset.buttonType).toBe('detailRequest'); + expect( + host + .querySelector('[data-testid="request-screen"]') + ?.getAttribute('data-quality-selector') + ).toBe('true'); + expect( + host + .querySelector('[data-testid="request-screen"]') + ?.getAttribute('data-quality') + ).toBe('hd'); + expect(axios.post).not.toHaveBeenCalled(); +}); +it('opens the permitted 4K target when HD cannot be requested', async () => { + state.permissions = Permission.REQUEST_4K_TV; + await render(); + await clickRequest(); + expect( + host + .querySelector('[data-testid="request-screen"]') + ?.getAttribute('data-quality') + ).toBe('4k'); +}); +it('opens a pending request for review instead of approving it on navigation', async () => { + state.permissions |= Permission.AUTO_APPROVE_TV | Permission.MANAGE_REQUESTS; + await render({ + media: { + status: MediaStatus.PENDING, + status4k: MediaStatus.UNKNOWN, + requests: [ + { + id: 11, + status: MediaRequestStatus.PENDING, + is4k: false, + requestedBy: { id: 7 }, + }, + ], + } as Media, + }); + await clickRequest(); + expect( + host + .querySelector('[data-testid="request-screen"]') + ?.getAttribute('data-edit') + ).toBe('11'); + expect(axios.post).not.toHaveBeenCalled(); +}); +it('keeps the entry disabled when no configured service is eligible', async () => { + state.services = []; + await render(); + expect((await clickRequest()).disabled).toBe(true); + expect(host.querySelector('[data-testid="request-screen"]')).toBeNull(); +}); +it('does not expose the entry without request permission', async () => { + state.permissions = 0; + await render(); + expect(host.querySelector('button')).toBeNull(); +}); +it('preserves split quality controls for consumers that have not opted in', async () => { + await render({ singleRequestEntry: false }); + expect( + host.querySelector('[data-testid="format-request-option-standard"]') + ?.textContent + ).toBe('HD'); + expect( + host.querySelector('[data-testid="format-request-option-4k"]')?.textContent + ).toBe('4K'); +}); diff --git a/src/components/RequestButton/index.tsx b/src/components/RequestButton/index.tsx index 4f81a48b1..c4f292b32 100644 --- a/src/components/RequestButton/index.tsx +++ b/src/components/RequestButton/index.tsx @@ -56,6 +56,7 @@ interface RequestButtonProps { buttonSize?: 'standard' | 'default' | 'sm'; buttonType?: 'primary' | 'ghost' | 'success' | 'detailRequest'; className?: string; + singleRequestEntry?: boolean; } const RequestButton = ({ @@ -67,7 +68,8 @@ const RequestButton = ({ is4kShowComplete = false, buttonSize = 'standard', buttonType = 'primary', - className = 'ml-2', + className, + singleRequestEntry = false, }: RequestButtonProps) => { const intl = useIntl(); const settings = useSettings(); @@ -402,6 +404,25 @@ const RequestButton = ({ return null; } + const entryOption = requestOptions.find((option) => !option.disabled); + const openRequestScreen = () => { + if (!entryOption || isModifying) { + return; + } + const is4k = entryOption.id === '4k'; + const pendingRequest = is4k ? active4kRequest : activeRequest; + setEditRequest( + !!pendingRequest && + (pendingRequest.requestedBy?.id === user?.id || + hasPermission(Permission.MANAGE_REQUESTS)) + ); + if (is4k) { + setShowRequest4kModal(true); + } else { + setShowRequestModal(true); + } + }; + return ( <> {showRequestModal && ( @@ -446,7 +467,25 @@ const RequestButton = ({ {button.text} ))} - + {singleRequestEntry ? ( + requestOptions.length > 0 && ( + + ) + ) : ( + + )} ); }; diff --git a/src/components/RequestCard/index.tsx b/src/components/RequestCard/index.tsx index 8c8fd7f23..f54876648 100644 --- a/src/components/RequestCard/index.tsx +++ b/src/components/RequestCard/index.tsx @@ -26,7 +26,12 @@ import { getTmdbPosterImageUrl } from '@app/utils/imageCache'; import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper'; import { withProperties } from '@app/utils/typeHelpers'; import { - ArrowPathIcon, + CheckIcon as AvailableStatusIcon, + ExclamationTriangleIcon as FailedStatusIcon, + ClockIcon as PendingStatusIcon, + ArrowPathIcon as ProcessingStatusIcon, +} from '@heroicons/react/24/outline'; +import { CheckIcon, PencilIcon, TrashIcon, @@ -55,7 +60,8 @@ const RequestModal = dynamic(() => import('@app/components/RequestModal'), { const messages = defineMessages('components.RequestCard', { failedretry: 'Something went wrong while retrying the request.', - searchAgain: 'Search Again', + retry: 'Retry', + retryRequest: 'Retry this request', failedmodify: 'Something went wrong while modifying the request.', mediaerror: '{mediaType} Not Found', tmdbid: 'TMDB ID', @@ -251,17 +257,19 @@ interface RequestCardPlaceholderProps { } const RequestCardPlaceholder = ({ compact }: RequestCardPlaceholderProps) => { + if (compact) { + return ( +
    @@ -606,7 +610,7 @@ const bordersDocument = `

    Borders

    ${paletteFilterControls}
    -

    Solid Border

    +

    Solid Border

    ${borderColumnHeadings} ${solidBorderCard('solid-steel')} @@ -617,7 +621,7 @@ const bordersDocument = `
    -

    Gradiant Border

    +

    Gradiant Border

    ${borderColumnHeadings} ${gradiantBorderCard(styleDisplayName('gradiant-steel'), 'gradiant-steel')} @@ -628,7 +632,7 @@ const bordersDocument = `
    -

    Metalic Border

    +

    Metalic Border

    ${borderColumnHeadings} ${borderCard(styleDisplayName('metalic-steel'), 'metalic-steel')} @@ -639,7 +643,7 @@ const bordersDocument = `
    -

    Metalic Border & Background

    +

    Metalic Border & Background

    ${borderColumnHeadings} ${borderCard(styleDisplayName('metalic-steel'), 'metalic-steel', false, true)} diff --git a/src/hooks/detailDisclosureMediaServer.test.mjs b/src/hooks/detailDisclosureMediaServer.test.mjs new file mode 100644 index 000000000..fc6762fc4 --- /dev/null +++ b/src/hooks/detailDisclosureMediaServer.test.mjs @@ -0,0 +1,181 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; + +// Evaluate pure preference helpers only: no server imports, HTTP or database. +const sourceRoot = + process.env.PIN_SOURCE_ROOT ?? + path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const read = (name) => fs.readFileSync(path.join(sourceRoot, name), 'utf8'); +const declarations = (source, names) => { + const ast = ts.createSourceFile( + 'pins.ts', + source, + ts.ScriptTarget.Latest, + true + ); + return ast.statements + .filter( + (node) => + ts.isVariableStatement(node) && + node.declarationList.declarations.some((declaration) => + names.includes(declaration.name.getText(ast)) + ) + ) + .map((node) => node.getText(ast)) + .join('\n'); +}; +const evaluate = (source, names) => { + const javascript = ts.transpileModule(declarations(source, names), { + compilerOptions: { + target: ts.ScriptTarget.ES2020, + module: ts.ModuleKind.CommonJS, + }, + }).outputText; + return new Function(`${javascript}\nreturn {${names.join(',')}};`)(); +}; +const hookSource = read('src/hooks/useDetailDisclosurePins.ts'); +const { defaultPins, fromUserSettings } = evaluate(hookSource, [ + 'defaultPins', + 'fromUserSettings', +]); +const serverSource = read('server/routes/user/usersettings.ts'); +const { serializeScopedDetailDisclosurePins, parseDetailDisclosurePinsBody } = + evaluate(serverSource, [ + 'hasOwn', + 'parseUserSettingsBodyObject', + 'serializeScopedDetailDisclosurePins', + 'parseDetailDisclosurePinsBody', + ]); + +test('new Media Server pin is off for missing and legacy TV preferences', () => { + assert.equal(defaultPins.mediaServer, false); + assert.equal(fromUserSettings(undefined, 'tv').mediaServer, false); + assert.deepEqual(serializeScopedDetailDisclosurePins(undefined, 'tv'), { + details: false, + mediaServer: false, + overview: false, + cast: false, + crew: false, + artists: false, + subjectTags: false, + }); + const legacy = { + detailDisclosureCastPinned: true, + detailDisclosureCrewPinned: true, + }; + assert.equal(fromUserSettings(legacy, 'tv').mediaServer, false); + assert.equal(fromUserSettings(legacy, 'movie').cast, true); + assert.equal(serializeScopedDetailDisclosurePins(legacy, 'movie').cast, true); +}); + +test('TV preference serialization preserves prior pins and the saved Media Server value', () => { + const settings = { + detailDisclosurePins: { + tv: { cast: true, crew: true, mediaServer: true }, + movie: { cast: false, collection: true }, + }, + }; + assert.equal(fromUserSettings(settings, 'tv').mediaServer, true); + assert.deepEqual(serializeScopedDetailDisclosurePins(settings, 'tv'), { + details: false, + mediaServer: true, + overview: false, + cast: true, + crew: true, + artists: false, + subjectTags: false, + }); + assert.equal( + serializeScopedDetailDisclosurePins(settings, 'movie').mediaServer, + undefined + ); + assert.equal( + serializeScopedDetailDisclosurePins(settings, 'movie').collection, + true + ); +}); + +test('the scoped TV allowlist accepts boolean Media Server values only', () => { + for (const mediaServer of [true, false]) + assert.deepEqual( + parseDetailDisclosurePinsBody( + { mediaServer, cast: true }, + false, + true, + true + ), + { value: { cast: true, mediaServer } } + ); + for (const mediaServer of ['true', 1, null, [], {}]) + assert.deepEqual( + parseDetailDisclosurePinsBody({ mediaServer }, false, true, true), + { error: 'mediaServer must be a boolean.' } + ); + assert.deepEqual( + parseDetailDisclosurePinsBody({ mediaServer: true }, true, true, false), + { value: {} } + ); + assert.deepEqual(parseDetailDisclosurePinsBody({ mediaServer: true }), { + value: {}, + }); + assert.match( + serverSource, + /parseDetailDisclosurePinsBody\(\s*req\.body,\s*mediaType === 'movie',\s*true,\s*mediaType === 'tv',\s*mediaType === 'tv'\s*\)/ + ); +}); + +test('pin updates preserve the existing media-scoped storage and partial-update path', () => { + assert.match( + serverSource, + /const updatedPins = \{ \.\.\.currentPins, \.\.\.parsedBody\.value \}/ + ); + assert.match(serverSource, /case 'tv':\s*nextPins\.tv = updatedPins/); + assert.match(serverSource, /user\.settings\.detailDisclosurePins = nextPins/); + assert.match( + hookSource, + /axios\.post\(endpoint, \{\s*\[section\]: pinned/ + ); + assert.match( + read('server/interfaces/api/userSettingsInterfaces.ts'), + /\| 'mediaServer'/ + ); +}); + +test('Overview is off by default, preserves saved pins, and accepts booleans only in the TV scope', () => { + assert.equal(defaultPins.overview, false); + assert.equal(fromUserSettings(undefined, 'tv').overview, false); + assert.equal( + serializeScopedDetailDisclosurePins(undefined, 'tv').overview, + false + ); + const settings = { + detailDisclosurePins: { tv: { overview: true, cast: true } }, + }; + assert.equal( + serializeScopedDetailDisclosurePins(settings, 'tv').overview, + true + ); + assert.equal(fromUserSettings(settings, 'tv').cast, true); + for (const overview of [true, false]) + assert.deepEqual( + parseDetailDisclosurePinsBody({ overview }, false, true, true, true), + { value: { overview } } + ); + for (const overview of ['true', 1, null, [], {}]) + assert.deepEqual( + parseDetailDisclosurePinsBody({ overview }, false, true, true, true), + { error: 'overview must be a boolean.' } + ); + assert.deepEqual( + parseDetailDisclosurePinsBody({ overview: true }, true, true, false, false), + { value: {} } + ); + assert.equal( + serializeScopedDetailDisclosurePins(undefined, 'movie').overview, + undefined + ); +}); diff --git a/src/hooks/detailDisclosurePinsMutation.test.ts b/src/hooks/detailDisclosurePinsMutation.test.ts index d0d4bdf69..0d87a18e9 100644 --- a/src/hooks/detailDisclosurePinsMutation.test.ts +++ b/src/hooks/detailDisclosurePinsMutation.test.ts @@ -3,9 +3,12 @@ import test from 'node:test'; import { DetailDisclosurePinsMutationState } from './detailDisclosurePinsMutation'; const initialPins = { + overview: false, details: false, advancedOptions: false, + taskFilters: false, collection: false, + mediaServer: false, cast: false, crew: false, artists: false, @@ -22,9 +25,12 @@ test('optimistically updates one detail disclosure pin without clearing others', const mutation = state.begin('crew', true); assert.deepStrictEqual(mutation.next, { + overview: false, details: false, advancedOptions: false, + taskFilters: false, collection: false, + mediaServer: false, cast: true, crew: true, artists: false, @@ -54,6 +60,17 @@ test('collection pin changes preserve cast pins and roll back independently', () assert.deepStrictEqual(state.rollback(mutation), previous); }); +test('media server pin defaults off and preserves other TV pins when changed', () => { + const state = new DetailDisclosurePinsMutationState(); + const defaultMutation = state.begin('mediaServer', true); + assert.strictEqual(defaultMutation.previous.mediaServer, false); + const previous = { ...initialPins, cast: true, crew: true }; + state.synchronize('user-1:tv', previous); + const mutation = state.begin('mediaServer', true); + assert.deepStrictEqual(mutation.next, { ...previous, mediaServer: true }); + assert.deepStrictEqual(state.rollback(mutation), previous); +}); + test('isolates detail disclosure pin mutations when the signed-in user changes', () => { const state = new DetailDisclosurePinsMutationState(); state.synchronize('user-1', initialPins); @@ -62,3 +79,12 @@ test('isolates detail disclosure pin mutations when the signed-in user changes', assert.strictEqual(state.isCurrent(oldUserMutation), false); }); + +test('Overview pin updates preserve independent sections and roll back on failure', () => { + const state = new DetailDisclosurePinsMutationState(); + const previous = { ...initialPins, mediaServer: true, details: true }; + state.synchronize('user-1:tv', previous); + const mutation = state.begin('overview', true); + assert.deepStrictEqual(mutation.next, { ...previous, overview: true }); + assert.deepStrictEqual(state.rollback(mutation), previous); +}); diff --git a/src/hooks/detailDisclosurePinsMutation.ts b/src/hooks/detailDisclosurePinsMutation.ts index eaafc024e..bafe6427e 100644 --- a/src/hooks/detailDisclosurePinsMutation.ts +++ b/src/hooks/detailDisclosurePinsMutation.ts @@ -17,9 +17,12 @@ export class DetailDisclosurePinsMutationState { private key = ''; private revision = 0; private value: DetailDisclosurePins = { + overview: false, details: false, advancedOptions: false, + taskFilters: false, collection: false, + mediaServer: false, cast: false, crew: false, artists: false, diff --git a/src/hooks/useDetailDisclosureOrder.ts b/src/hooks/useDetailDisclosureOrder.ts new file mode 100644 index 000000000..424a83985 --- /dev/null +++ b/src/hooks/useDetailDisclosureOrder.ts @@ -0,0 +1,64 @@ +import { useUser } from '@app/hooks/useUser'; +import { + normalizeSeriesDisclosureOrder, + type SeriesDisclosureRole, +} from '@server/utils/detailDisclosureOrder'; +import axios from 'axios'; +import { useCallback, useMemo, useRef } from 'react'; +import useSWR from 'swr'; + +const useDetailDisclosureOrder = (enabled = true) => { + const { user } = useUser(); + const userId = String(user?.id ?? '').match(/^[1-9]\d{0,8}$/)?.[0]; + const endpoint = + enabled && userId + ? `/api/v1/user/${userId}/settings/detail-disclosure-order/tv` + : null; + const { data, mutate } = useSWR(endpoint, { + revalidateOnFocus: false, + }); + const order = useMemo( + () => + normalizeSeriesDisclosureOrder( + data ?? user?.settings?.detailDisclosureOrder?.tv + ), + [data, user?.settings?.detailDisclosureOrder?.tv] + ); + const identity = useRef(endpoint); + identity.current = endpoint; + const queue = useRef>(Promise.resolve()); + const setOrder = useCallback( + (next: SeriesDisclosureRole[]) => { + if (!endpoint) return Promise.resolve(); + const original = order; + const save = async () => { + if (identity.current !== endpoint) return; + await mutate(next, { revalidate: false }); + try { + const response = await axios.post(endpoint, { + order: next, + }); + if (identity.current === endpoint) + await mutate(normalizeSeriesDisclosureOrder(response.data), { + revalidate: false, + }); + } catch (error) { + if (identity.current === endpoint) + await mutate(original, { revalidate: false }); + throw error; + } + }; + const result = queue.current.catch(() => undefined).then(save); + queue.current = result; + return result; + }, + [endpoint, mutate, order] + ); + return { + order, + setOrder, + canReorder: Boolean(endpoint), + preferenceKey: endpoint ?? 'read-only', + }; +}; +export default useDetailDisclosureOrder; diff --git a/src/hooks/useDetailDisclosurePins.ts b/src/hooks/useDetailDisclosurePins.ts index 05a1a3f2f..263ac5f14 100644 --- a/src/hooks/useDetailDisclosurePins.ts +++ b/src/hooks/useDetailDisclosurePins.ts @@ -13,12 +13,15 @@ import { } from './detailDisclosurePinsMutation'; const defaultPins: DetailDisclosurePins = { + overview: false, details: false, advancedOptions: false, + taskFilters: false, filters: false, mediaFilters: false, sortBy: false, collection: false, + mediaServer: false, cast: false, crew: false, artists: false, @@ -30,12 +33,15 @@ const fromUserSettings = ( mediaType: DetailDisclosureMediaType ): DetailDisclosurePins => { const legacyPins: DetailDisclosurePins = { + overview: false, details: false, advancedOptions: false, + taskFilters: false, filters: false, mediaFilters: false, sortBy: false, collection: false, + mediaServer: false, cast: mediaType === 'movie' && settings?.detailDisclosureCastPinned === true, crew: diff --git a/src/hooks/useDiscover.ts b/src/hooks/useDiscover.ts index 8030d1842..f8373c8a3 100644 --- a/src/hooks/useDiscover.ts +++ b/src/hooks/useDiscover.ts @@ -20,7 +20,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useIntl } from 'react-intl'; import useSWRInfinite from 'swr/infinite'; import useSettings from './useSettings'; -import { useUser } from './useUser'; +import { Permission, useUser } from './useUser'; export { encodeURIExtraParams } from '@server/utils/discoverQuery'; @@ -172,7 +172,8 @@ const useDiscover = < } = {} ): DiscoverResult => { const settings = useSettings(); - const { user } = useUser(); + const { user, hasPermission } = useUser(); + const canManageBlocklist = hasPermission(Permission.MANAGE_BLOCKLIST); const { addToast } = useToasts(); const intl = useIntl(); const router = useRouter(); @@ -303,7 +304,10 @@ const useDiscover = < ); } - if (hideBlocklisted) { + if ( + hideBlocklisted && + (!canManageBlocklist || settings.currentSettings.hideBlocklisted) + ) { filteredTitles = filteredTitles.filter( (i) => !i.mediaInfo || i.mediaInfo.status !== MediaStatus.BLOCKLISTED ); @@ -315,7 +319,9 @@ const useDiscover = < availableQuality, hideAvailable, hideBlocklisted, + canManageBlocklist, settings.currentSettings.hideAvailable, + settings.currentSettings.hideBlocklisted, ]); const lastResultPage = data?.[data.length - 1]; diff --git a/src/hooks/useUser.ts b/src/hooks/useUser.ts index d33fd4c6b..dae827c7d 100644 --- a/src/hooks/useUser.ts +++ b/src/hooks/useUser.ts @@ -11,6 +11,7 @@ import type { PermissionCheckOptions } from '@server/lib/permissions'; import { Permission, hasPermission } from '@server/lib/permissions'; import type { NotificationAgentKey } from '@server/lib/settings'; import type { AdvancedThemeOverrides } from '@server/utils/advancedThemeOverrides'; +import type { DetailDisclosureOrder } from '@server/utils/detailDisclosureOrder'; import { useRouter } from 'next/router'; import type { MutatorCallback } from 'swr'; import useSWR from 'swr'; @@ -61,6 +62,7 @@ export interface UserSettings { detailDisclosureArtistsPinned?: boolean; detailDisclosureSubjectTagsPinned?: boolean; detailDisclosurePins?: UserSettingsDetailDisclosuresByMedia; + detailDisclosureOrder?: DetailDisclosureOrder; mediaFilterPins?: UserMediaFilterPins; advancedThemeOverrides?: AdvancedThemeOverrides | null; } diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index b582e4d7d..0384ce3ac 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -84,6 +84,7 @@ "components.Association.Filters.year": "Release Year", "components.Association.alsoconnected": "Also connected", "components.Association.associations": "Associations", + "components.Association.associationsDescription": "View media related or similar to this title.", "components.Association.books": "Same Author", "components.Association.browseMore": "Browse More...", "components.Association.empty": "No associations found yet", @@ -227,8 +228,8 @@ "components.CollectionDetails.Associations.empty": "No collection associations are available.", "components.CollectionDetails.Metadata.fullCastList": "Full Cast List", "components.CollectionDetails.Metadata.fullCrewList": "Full Crew List", - "components.CollectionDetails.Metadata.noCast": "No cast information available", - "components.CollectionDetails.Metadata.noCrew": "No crew information available", + "components.CollectionDetails.Metadata.noCast": "No Cast Information Available", + "components.CollectionDetails.Metadata.noCrew": "No Crew Information Available", "components.CollectionDetails.Metadata.noTags": "No subject tags available", "components.CollectionDetails.Metadata.subjectTags": "Subject Tags", "components.CollectionDetails.Metadata.viewCast": "Cast", @@ -371,6 +372,8 @@ "components.Common.MediaServerPlayButton.playOnServer4k": "Play on {mediaServerName} (4K)", "components.Common.MediaServerPlayButton.playlistHelp": "Create a playback playlist from the selected items and open it in your media server.", "components.Common.MediaServerPlayButton.unavailable": "No playable media is currently available on {mediaServerName}.", + "components.Common.PageStatus.loading": "Loading", + "components.Common.PageStatus.searching": "Searching", "components.Common.PaginationFooter.page": "Page {page} of {pages}", "components.Common.PaginationFooter.pagination": "Pagination", "components.Common.PaginationFooter.resultsPerPage": "Results Per Page", @@ -603,8 +606,14 @@ "components.Discover.DiscoverSliderEdit.deletesuccess": "Sucessfully deleted slider.", "components.Discover.DiscoverSliderEdit.enable": "Toggle Visibility", "components.Discover.DiscoverSliderEdit.remove": "Remove", + "components.Discover.DiscoverTv.disableWatchlistPreview": "Disable Watchlist", + "components.Discover.DiscoverTv.enableWatchlistPreview": "Enable Watchlist", + "components.Discover.DiscoverTv.loadErrorDescription": "The discovery service did not respond, please try again.", + "components.Discover.DiscoverTv.loadErrorTitle": "Series Could Not Be Loaded", "components.Discover.DiscoverTv.networkSeries": "{network} Series", + "components.Discover.DiscoverTv.retryDescription": "Retry loading series using the current filters.", "components.Discover.DiscoverTv.series": "Series", + "components.Discover.DiscoverTv.watchlistPreviewHelp": "Enable or disable the Watchlist buttons.", "components.Discover.DiscoverTvGenre.genreSeries": "{genre} Series", "components.Discover.DiscoverTvKeyword.keywordSeries": "{keywordTitle} Series", "components.Discover.DiscoverTvLanguage.languageSeries": "{language} Series", @@ -972,7 +981,6 @@ "components.Layout.VersionStatus.outofdate": "Out of Date", "components.Layout.VersionStatus.streammain": "SeerrNG Main", "components.Layout.VersionStatus.streamstable": "SeerrNG Stable", - "components.Layout.searching": "Searching", "components.ListView.continueSearch": "Continue Search", "components.Login.JellyfinQuickConnectModal.cancel": "Cancel", "components.Login.JellyfinQuickConnectModal.instructions": "Enter this code in your {mediaServerName} app", @@ -1133,11 +1141,41 @@ "components.MediaDetails.AlbumTrackList.track": "Track", "components.MediaDetails.DetailDisclosure.pin": "Pin {label} open across detail pages", "components.MediaDetails.DetailDisclosure.unpin": "Unpin {label}", + "components.MediaDetails.DisclosureOrder.failed": "Your button order could not be saved. Your previous order has been restored. Please try again.", + "components.MediaDetails.DisclosureOrder.instructions": "Hold a button for one second to move it. Or use Shift + Alt + Left or Right Arrow to reorder. Press Escape to cancel.", + "components.MediaDetails.DisclosureOrder.moved": "{label} moved to position {position}.", + "components.MediaDetails.DisclosureOrder.ready": "{label} is ready to move. Drag it between the other buttons.", "components.MediaDetails.MediaQualitySelect.report": "Report an issue with the {quality} version.", "components.MediaDetails.MediaQualitySelect.request": "Request the {quality} version.", "components.MediaDetails.MediaQualitySelect.select": "Use {quality} for playback.", "components.MediaDetails.MediaQualitySelect.selectQuality": "Select Quality", "components.MediaDetails.MediaQualitySelect.unavailable": "This quality is not available in your library.", + "components.MediaDetails.MediaServerCollectionButton.addCollection": "Add to Collection", + "components.MediaDetails.MediaServerCollectionButton.addNamed": "Add to {name}", + "components.MediaDetails.MediaServerCollectionButton.busy": "The {server} collection state is loading or updating.", + "components.MediaDetails.MediaServerCollectionButton.empty": "Create a collection in {server} first. SeerrNG only adds titles to existing collections.", + "components.MediaDetails.MediaServerCollectionButton.error": "The collections could not be loaded or the change could not be confirmed. Reload their current state before changing membership.", + "components.MediaDetails.MediaServerCollectionButton.errorTitle": "Media Server Collections Unavailable", + "components.MediaDetails.MediaServerCollectionButton.help": "Choose an existing shared {server} collection to add or remove this entire series. Selected episodes are not added separately.", + "components.MediaDetails.MediaServerCollectionButton.limit": "The {server} collection list exceeds the supported limit. No partial list is offered.", + "components.MediaDetails.MediaServerCollectionButton.link": "Link your own {server} account before changing its collections.", + "components.MediaDetails.MediaServerCollectionButton.missing": "This series is not available in your accessible {server} library for the selected quality.", + "components.MediaDetails.MediaServerCollectionButton.permission": "Your linked {server} account does not have permission to manage these shared collections.", + "components.MediaDetails.MediaServerCollectionButton.removeNamed": "Remove from {name}", + "components.MediaDetails.MediaServerCollectionButton.retry": "Reload collection membership from your linked media-server account. This does not repeat an add or remove action.", + "components.MediaDetails.MediaServerCollectionButton.unavailable": "Collections are unavailable for the configured media server.", + "components.MediaDetails.MediaServerWatchlistButton.addFavorites": "Add to Favorites", + "components.MediaDetails.MediaServerWatchlistButton.addHelp": "Add this entire series to your {server} {list}. Selected seasons and episodes are not added separately.", + "components.MediaDetails.MediaServerWatchlistButton.addWatchlist": "Add to Watchlist", + "components.MediaDetails.MediaServerWatchlistButton.failed": "The saved state could not be loaded or updated. Please retry before changing it.", + "components.MediaDetails.MediaServerWatchlistButton.failedTitle": "Media Server Saved State Unavailable", + "components.MediaDetails.MediaServerWatchlistButton.linkHelp": "Link your own {server} account before saving this series.", + "components.MediaDetails.MediaServerWatchlistButton.missingHelp": "This series could not be matched in your accessible {server} catalog.", + "components.MediaDetails.MediaServerWatchlistButton.pendingHelp": "The {server} saved state is loading or updating.", + "components.MediaDetails.MediaServerWatchlistButton.removeFavorites": "Remove from Favorites", + "components.MediaDetails.MediaServerWatchlistButton.removeHelp": "Remove this entire series from your {server} {list}. This does not remove media or change your Seerr watchlist.", + "components.MediaDetails.MediaServerWatchlistButton.removeWatchlist": "Remove from Watchlist", + "components.MediaDetails.MediaServerWatchlistButton.retry": "Reload the saved state from your own media-server account. This does not repeat an add or remove action.", "components.MediaDetails.MetadataAttribution.label": "Metadata sources", "components.MediaDetails.MetadataAttribution.tmdb": "TMDB", "components.MediaDetails.MetadataAttribution.tmdbTitle": "View metadata source on TMDB.", @@ -1153,24 +1191,13 @@ "components.MediaDetails.PlaybackTrackList.selection": "Select items to play", "components.MediaDetails.PlaybackTrackList.title": "Title", "components.MediaDetails.PlaybackTrackList.track": "Track", - "components.MediaDetails.SeriesBrowser.availabilityLegend": "Bright green check: fully available. Dark green check: partially available. Red X: not available.", - "components.MediaDetails.SeriesBrowser.deselectSeasonEpisodes": "Clear this season from the playback selection", - "components.MediaDetails.SeriesBrowser.episode": "Episode", - "components.MediaDetails.SeriesBrowser.episodeNumber": "Episode {number}", - "components.MediaDetails.SeriesBrowser.episodes": "Episodes", - "components.MediaDetails.SeriesBrowser.loadError": "Episodes could not be loaded. Try selecting the season again.", + "components.MediaDetails.SeriesBrowser.loadError": "Episodes Could Not Be Loaded", + "components.MediaDetails.SeriesBrowser.loadErrorHint": "Episode information could not be fetched, please try again.", "components.MediaDetails.SeriesBrowser.noSeasons": "No Seasons Available", - "components.MediaDetails.SeriesBrowser.season": "Season", + "components.MediaDetails.SeriesBrowser.retryEpisodesTooltip": "Fetch the seasons and episodes again without changing your playback selection.", "components.MediaDetails.SeriesBrowser.seasonNumber": "Season {number}", - "components.MediaDetails.SeriesBrowser.selectSeason": "Select a season to view its episodes", - "components.MediaDetails.SeriesBrowser.selectSeasonEpisodes": "Select every available episode in this season", - "components.MediaDetails.SeriesBrowser.selection": "Select items to play", "components.MediaDetails.SeriesBrowser.specials": "Specials", - "components.MediaDetails.SeriesBrowser.title": "Title", "components.MediaDetails.SeriesBrowser.untitled": "Untitled", - "components.MediaDetails.SeriesBrowser.unwatched": "Unwatched", - "components.MediaDetails.SeriesBrowser.unwatchedCount": "{count} unwatched {count, plural, one {episode} other {episodes}}", - "components.MediaDetails.SeriesBrowser.watched": "Watched", "components.MediaSlider.ShowMoreCard.seemore": "See More", "components.MetadataSelector.selectMetdataProvider": "Select a metadata provider", "components.MetadataSelector.tmdbLabel": "The Movie Database (TMDB)", @@ -1183,14 +1210,12 @@ "components.MovieDetails.Layout.fullCrewList": "Full Crew List", "components.MovieDetails.Layout.genres": "Genres", "components.MovieDetails.Layout.hd": "HD", - "components.MovieDetails.Layout.imdbScore": "IMDb user score", - "components.MovieDetails.Layout.imdbUserScore": "IMDB User Score – votes: {formattedCount}", "components.MovieDetails.Layout.language": "Language", "components.MovieDetails.Layout.mediaAndFormat": "Media & Format", "components.MovieDetails.Layout.minutes": "{minutes} minutes", "components.MovieDetails.Layout.movieDetails": "Details", - "components.MovieDetails.Layout.noCast": "No cast information available", - "components.MovieDetails.Layout.noCrew": "No crew information available", + "components.MovieDetails.Layout.noCast": "No Cast Information Available", + "components.MovieDetails.Layout.noCrew": "No Crew Information Available", "components.MovieDetails.Layout.noTags": "No subject tags available", "components.MovieDetails.Layout.notAvailable": "Not available", "components.MovieDetails.Layout.overview": "Overview", @@ -1201,8 +1226,6 @@ "components.MovieDetails.Layout.releaseDate": "Release Date", "components.MovieDetails.Layout.releaseDates": "Release Dates", "components.MovieDetails.Layout.revenue": "Revenue", - "components.MovieDetails.Layout.rtAudienceScore": "Rotten Tomatoes Audience Score", - "components.MovieDetails.Layout.rtCriticsScore": "Rotten Tomatoes Tomatometer", "components.MovieDetails.Layout.runtime": "Runtime", "components.MovieDetails.Layout.screenplay": "Screenplay", "components.MovieDetails.Layout.similar": "Similar Titles", @@ -1211,7 +1234,6 @@ "components.MovieDetails.Layout.studios": "Studios", "components.MovieDetails.Layout.subjectTags": "Subject Tags", "components.MovieDetails.Layout.theatrical": "Theatrical", - "components.MovieDetails.Layout.tmdbUserScore": "TMDB User Score", "components.MovieDetails.Layout.ultraHd": "4K", "components.MovieDetails.Layout.viewCast": "Cast", "components.MovieDetails.Layout.viewCollection": "Collection", @@ -1492,7 +1514,8 @@ "components.RequestCard.failedretry": "Something went wrong while retrying the request.", "components.RequestCard.mediaerror": "{mediaType} Not Found", "components.RequestCard.partialBookService": "Partial Bookshelf link", - "components.RequestCard.searchAgain": "Search Again", + "components.RequestCard.retry": "Retry", + "components.RequestCard.retryRequest": "Retry this request", "components.RequestCard.tmdbid": "TMDB ID", "components.RequestCard.tvdbid": "TheTVDB ID", "components.RequestCard.unknowntitle": "Unknown Title", @@ -1530,11 +1553,15 @@ "components.RequestList.RequestItem.watchAheadSaveError": "Could not update the requested episode queue. Check that your media server and Sonarr are connected.", "components.RequestList.RequestItem.watchAheadSaved": "Requested episode queue updated.", "components.RequestList.RequestItem.watchAheadTitle": "Episode Queue", + "components.RequestList.mediaFilters": "Media Filters", + "components.RequestList.mediaType": "Media Type", "components.RequestList.requests": "Requests", "components.RequestList.showallrequests": "Show All Requests", "components.RequestList.sortAdded": "Most Recent", + "components.RequestList.sortBy": "Sort By", "components.RequestList.sortDirection": "Toggle Sort Direction", "components.RequestList.sortModified": "Last Modified", + "components.RequestList.taskFilters": "Task Filters", "components.RequestList.unableToConnect": "Unable to connect to {services}. Some information may be unavailable.", "components.RequestModal.AdvancedRequester.advancedoptions": "Advanced Request", "components.RequestModal.AdvancedRequester.animenote": "* This series is an anime.", @@ -1794,6 +1821,14 @@ "components.RequestModal.RequestFooterStatus.approvalRequired": "Approval Required", "components.RequestModal.RequestFooterStatus.approvedAutomatically": "Automatically", "components.RequestModal.RequestFooterStatus.requested": "Requested", + "components.RequestModal.RequestTree.loadError": "Episodes Could Not Be Loaded", + "components.RequestModal.RequestTree.loadErrorHint": "Your request selection is preserved. Load all season information before changing it.", + "components.RequestModal.RequestTree.loading": "Loading Episodes", + "components.RequestModal.RequestTree.noSeasons": "No Seasons Available", + "components.RequestModal.RequestTree.retryTooltip": "Fetch the seasons and episodes again without changing your request selection.", + "components.RequestModal.RequestTree.seasonNumber": "Season {number}", + "components.RequestModal.RequestTree.specials": "Specials", + "components.RequestModal.RequestTree.untitled": "Untitled", "components.RequestModal.SearchByNameModal.nomatches": "We were unable to find a match for this series.", "components.RequestModal.SearchByNameModal.notvdbiddescription": "We were unable to automatically match this series. Please select the correct match below.", "components.RequestModal.SeasonEpisodeSelector.availabilityLegend": "Bright green check: fully available. Dark green check: partially available. Red X: not available.", @@ -1821,6 +1856,7 @@ "components.RequestModal.cancel": "Cancel Request", "components.RequestModal.edit": "Edit Request", "components.RequestModal.episodes": "Episodes", + "components.RequestModal.episodesLoading": "Load episode information before submitting this selection.", "components.RequestModal.errorediting": "Something went wrong while editing the request.", "components.RequestModal.genres": "Genres", "components.RequestModal.hd": "HD", @@ -1866,9 +1902,9 @@ "components.RequestModal.status": "Status", "components.RequestModal.studio": "Studio", "components.RequestModal.ultraHd": "4K", - "components.RequestModal.watchAheadDescription": "This optional queue is Off by default for every TV request. If you turn it on, SeerrNG follows your linked media server playback and keeps this many upcoming episodes requested in Sonarr after the request is approved. Episodes use the parent approval and do not count against your request quota. Turning it off stops future additions but does not cancel episodes already requested.", + "components.RequestModal.watchAheadDescription": "Off by default. Follows your linked playback and keeps this many upcoming episodes requested. Uses 1 request. Turning it off stops future additions, not existing requests.", "components.RequestModal.watchAheadEpisodeOption": "{count, plural, one {# episode} other {# episodes}}", - "components.RequestModal.watchAheadLabel": "Requested Episode Queue", + "components.RequestModal.watchAheadLabel": "Episode Queue", "components.RequestModal.watchAheadOff": "Off", "components.RequestStatus.SoftwareRequests.approve": "Approve", "components.RequestStatus.SoftwareRequests.approved": "Approved", @@ -1880,7 +1916,7 @@ "components.RequestStatus.SoftwareRequests.cancelled": "Cancelled", "components.RequestStatus.SoftwareRequests.clearCancelled": "Clear cancelled request", "components.RequestStatus.SoftwareRequests.clearCancelledDescription": "This removes the cancelled request and its saved status history from Seerr. It does not delete installed software.", - "components.RequestStatus.SoftwareRequests.clearCancelledTitle": "Clear this cancelled request?", + "components.RequestStatus.SoftwareRequests.clearCancelledTitle": "Clear This Cancelled Request?", "components.RequestStatus.SoftwareRequests.clearFailed": "Unable to clear this cancelled request.", "components.RequestStatus.SoftwareRequests.clearSuccess": "Cancelled request cleared.", "components.RequestStatus.SoftwareRequests.confirmAfterCheck": "I checked; continue", @@ -1891,11 +1927,11 @@ "components.RequestStatus.SoftwareRequests.downloadNamed": "Download {name}", "components.RequestStatus.SoftwareRequests.downloading": "Downloading", "components.RequestStatus.SoftwareRequests.failed": "Failed", - "components.RequestStatus.SoftwareRequests.game": "PC game", - "components.RequestStatus.SoftwareRequests.hideHistory": "Hide status history", + "components.RequestStatus.SoftwareRequests.game": "PC Game", + "components.RequestStatus.SoftwareRequests.history": "History", "components.RequestStatus.SoftwareRequests.historyError": "Status history could not be loaded.", "components.RequestStatus.SoftwareRequests.historyLoading": "Loading status history…", - "components.RequestStatus.SoftwareRequests.importing": "Verifying import", + "components.RequestStatus.SoftwareRequests.importing": "Verifying Import", "components.RequestStatus.SoftwareRequests.linux": "Linux", "components.RequestStatus.SoftwareRequests.loadError": "Software request status could not be loaded.", "components.RequestStatus.SoftwareRequests.macos": "macOS", @@ -1903,17 +1939,16 @@ "components.RequestStatus.SoftwareRequests.modern": "Modern", "components.RequestStatus.SoftwareRequests.noHistory": "No saved status updates are available.", "components.RequestStatus.SoftwareRequests.noRequests": "No software requests yet.", - "components.RequestStatus.SoftwareRequests.operatingSystem": "Operating system: {value}", - "components.RequestStatus.SoftwareRequests.pending": "Pending approval", + "components.RequestStatus.SoftwareRequests.operatingSystem": "Operating System: {value}", + "components.RequestStatus.SoftwareRequests.pending": "Pending Approval", "components.RequestStatus.SoftwareRequests.quotaExceeded": "Your software request limit has been reached.", "components.RequestStatus.SoftwareRequests.requestedBy": "Requested by {user}", "components.RequestStatus.SoftwareRequests.retro": "Retro", "components.RequestStatus.SoftwareRequests.retry": "Retry", "components.RequestStatus.SoftwareRequests.retryCheckRequired": "The provider cannot confirm whether the previous download started. Check the download client’s queue and history. Continue only if no matching download exists.", "components.RequestStatus.SoftwareRequests.searching": "Searching", - "components.RequestStatus.SoftwareRequests.showHistory": "Show status history", "components.RequestStatus.SoftwareRequests.submitted": "Requested {date}", - "components.RequestStatus.SoftwareRequests.title": "Software requests", + "components.RequestStatus.SoftwareRequests.title": "Software Requests", "components.RequestStatus.SoftwareRequests.universal": "Universal", "components.RequestStatus.SoftwareRequests.windows": "Windows", "components.RequestStatus.SoftwareRequests.withdraw": "Withdraw", @@ -1923,14 +1958,14 @@ "components.RequestStatus.deleteDescription": "Seerr will cancel any active work it can identify, clean up temporary request records, and permanently remove this entry and its history.", "components.RequestStatus.deleteFailed": "Unable to delete this request entry.", "components.RequestStatus.deleteSuccess": "Request entry deleted.", - "components.RequestStatus.deleteTitle": "Delete request entry?", + "components.RequestStatus.deleteTitle": "Delete Request Entry?", "components.RequestStatus.deleteTooltip": "Delete this request and its status history.", "components.RequestStatus.deleting": "Deleting…", "components.RequestStatus.remove": "Delete From Library", "components.RequestStatus.removeDescription": "This will permanently delete its media files and remove its library entry from {service}. This action cannot be undone from Seerr. Other quality versions remain. When the last movie or series copy is confirmed gone, library reconciliation also removes its requests and issues, but preserves blocklists and watchlists.", "components.RequestStatus.removeFailed": "Unable to delete this item from its library service.", "components.RequestStatus.removeSuccess": "Item deleted from its library service.", - "components.RequestStatus.removeTitle": "Permanently delete {title} from {service}?", + "components.RequestStatus.removeTitle": "Permanently Delete {title} from {service}?", "components.RequestStatus.removeTooltip": "The media and the library entry will both be deleted.", "components.RequestStatus.removeUnavailableTooltip": "No linked library item is available to delete.", "components.RequestStatus.removing": "Deleting…", @@ -1964,7 +1999,7 @@ "components.Requests.deleteDescription": "Seerr will cancel any active work it can identify, clean up temporary request records, and permanently remove this entry and its history.", "components.Requests.deleteFailed": "Unable to delete this request entry.", "components.Requests.deleteSuccess": "Request entry deleted.", - "components.Requests.deleteTitle": "Delete request entry?", + "components.Requests.deleteTitle": "Delete Request Entry?", "components.Requests.deleteTooltip": "Delete this request and its status history.", "components.Requests.deleted": "Deleted", "components.Requests.deleting": "Deleting…", @@ -1978,15 +2013,16 @@ "components.Requests.ebooks": "Books", "components.Requests.edit": "Edit", "components.Requests.editTooltip": "Edit this pending request.", + "components.Requests.emptyTitle": "No Requests Found", "components.Requests.eta": "ETA: {date}", "components.Requests.failed": "Failed", "components.Requests.filter": "Filters", + "components.Requests.filteredEmptyTitle": "Filters Too Restrictive", "components.Requests.firstPublished": "First Published", "components.Requests.format": "Format", "components.Requests.fourK": "4K", "components.Requests.genres": "Genres", "components.Requests.hd": "HD", - "components.Requests.hideHistory": "Hide History", "components.Requests.history": "History", "components.Requests.importing": "Importing", "components.Requests.incomplete": "Incomplete", @@ -1996,15 +2032,15 @@ "components.Requests.last6Months": "Last 6 months", "components.Requests.last7Days": "Last 7 days", "components.Requests.latestIssue": "Latest Issue", - "components.Requests.library": "Adding to library", - "components.Requests.loadError": "Request status could not be loaded.", - "components.Requests.loadErrorHint": "The request service did not respond. Try again.", - "components.Requests.loading": "Loading request status", - "components.Requests.loadingTitle": "Loading title…", + "components.Requests.library": "Adding to Library", + "components.Requests.loadError": "Request Status Could Not Be Loaded", + "components.Requests.loadErrorHint": "The request service did not respond, please try again.", + "components.Requests.loading": "Loading Request Status", + "components.Requests.loadingTitle": "Loading Title…", "components.Requests.magazine": "Magazine", "components.Requests.magazines": "Magazines", "components.Requests.manageRequests": "Manage Requests", - "components.Requests.mediaAndFormat": "Media & format", + "components.Requests.mediaAndFormat": "Media & Format", "components.Requests.mediaFilters": "Media Filters", "components.Requests.mediaType": "Media Type", "components.Requests.mediaTypeValue": "Media Type", @@ -2022,8 +2058,9 @@ "components.Requests.notAvailable": "Not available", "components.Requests.olderRequests": "{count, plural, =1 {# older request is outside this window.} other {# older requests are outside this window.}}", "components.Requests.pages": "Pages", + "components.Requests.pcGames": "PC Games", "components.Requests.pending": "Pending", - "components.Requests.processing": "Active", + "components.Requests.processing": "Processing", "components.Requests.progressFrom": "{percent}% complete", "components.Requests.progressUnavailable": "Download service did not provide progress data.", "components.Requests.publisher": "Publisher", @@ -2034,12 +2071,12 @@ "components.Requests.removeDescription": "This will permanently delete its media files and remove its library entry from {service}. This action cannot be undone from Seerr. Other quality versions remain. When the last movie or series copy is confirmed gone, library reconciliation also removes its requests and issues, but preserves blocklists and watchlists.", "components.Requests.removeFailed": "Unable to delete this item from its library service.", "components.Requests.removeSuccess": "Item deleted from its library service.", - "components.Requests.removeTitle": "Permanently delete {title} from {service}?", + "components.Requests.removeTitle": "Permanently Delete {title} from {service}?", "components.Requests.removeTooltip": "The media and the library entry will both be deleted.", "components.Requests.removeUnavailableTooltip": "No linked library item is available to delete.", "components.Requests.removing": "Deleting…", "components.Requests.requestDate": "Date", - "components.Requests.requestLifecycle": "Request lifecycle", + "components.Requests.requestLifecycle": "Request Lifecycle", "components.Requests.requestTime": "Time", "components.Requests.requested": "Requested", "components.Requests.requestedAt": "Requested {date}", @@ -2048,10 +2085,13 @@ "components.Requests.requestedDateTime": "Requested On", "components.Requests.retry": "Retry", "components.Requests.retryFailed": "Unable to retry this request.", - "components.Requests.retryLoad": "Try Again", + "components.Requests.retryLoad": "Retry", + "components.Requests.retryLoadTooltip": "Reload request information from Seerr. This does not restart or download any media.", "components.Requests.retrySuccess": "Request queued for another attempt.", "components.Requests.retryTooltip": "Restart this failed request from approval.", "components.Requests.retrying": "Retrying…", + "components.Requests.romsModern": "ROMs - Modern", + "components.Requests.romsRetro": "ROMs - Retro", "components.Requests.runtime": "Runtime", "components.Requests.scrollProgressLeft": "Scroll progress left", "components.Requests.scrollProgressRight": "Scroll progress right", @@ -2086,9 +2126,10 @@ "components.Requests.title": "Requests", "components.Requests.trackCount": "Track Count", "components.Requests.unavailable": "Unavailable", - "components.Requests.unknownTitle": "Unknown title", + "components.Requests.unknownTitle": "Unknown Title", "components.Requests.userFilter": "Select User", "components.Requests.viewAllHistory": "View All History", + "components.Requests.viewAllHistoryTooltip": "Remove the time-period filter to include older requests. Other filters remain unchanged.", "components.Requests.watchAheadDescription": "This optional queue is Off by default for every TV request. If you turn it on, SeerrNG follows your linked media server playback and keeps this many upcoming episodes requested in Sonarr after the request is approved. Episodes use the parent approval and do not count against your request quota. Turning it off stops future additions but does not cancel episodes already requested.", "components.Requests.watchAheadEpisodeBadge": "Auto-Queued", "components.Requests.watchAheadEpisodeBadgeTooltip": "Automatically requested by the Episode Queue as playback progressed.", @@ -3421,6 +3462,7 @@ "components.Setup.transportRecoveryTitle": "Sign in again to continue setup", "components.Setup.transportRequired": "Choose an active HTTPS transport or explicitly enable authenticated HTTP sessions before signing in.", "components.Setup.welcome": "Welcome to Seerr", + "components.Slider.emptyTitle": "No Results", "components.SoftwareCatalog.allPcPlatforms": "All PC platforms", "components.SoftwareCatalog.allSystems": "All systems", "components.SoftwareCatalog.arm64": "ARM64", @@ -3484,6 +3526,9 @@ "components.StatusChecker.restartRequired": "Server Restart Required", "components.StatusChecker.restartRequiredDescription": "Please restart the server to apply the updated settings.", "components.TitleCard.addToWatchList": "Add to watchlist", + "components.TitleCard.blocklistAddDescription": "Add {title} to the Blocklist. Users without Blocklist management permission will not see it in browsing.", + "components.TitleCard.blocklistRemoveDescription": "Remove {title} from the Blocklist and restore normal browsing visibility.", + "components.TitleCard.blocklistUpdatingDescription": "The Blocklist is updating. Please wait.", "components.TitleCard.cleardata": "Clear Data", "components.TitleCard.magazineAvailableReason": "This magazine is already available.", "components.TitleCard.magazinePartiallyAvailableReason": "Some issues of this magazine are already available.", @@ -3499,6 +3544,10 @@ "components.TitleCard.watchlistCancel": "watchlist for {title} canceled.", "components.TitleCard.watchlistDeleted": "{title} Removed from watchlist successfully!", "components.TitleCard.watchlistError": "Something went wrong. Please try again.", + "components.TitleCard.watchlistPreviewAdd": "Add {title} to your Watchlist.", + "components.TitleCard.watchlistPreviewDisabled": "Watchlist is disabled. Use Enable Watchlist above the posters to enable it.", + "components.TitleCard.watchlistPreviewLabel": "Watchlist", + "components.TitleCard.watchlistPreviewRemove": "Remove {title} from your Watchlist.", "components.TitleCard.watchlistSuccess": "{title} added to watchlist successfully!", "components.TrackingControls.cancel": "Cancel", "components.TrackingControls.choose": "Choose a rating", @@ -3592,6 +3641,12 @@ "components.TvCollectionRequestModal.selectItemsToRequest": "Select at least one series to request.", "components.TvCollectionRequestModal.selection": "Select this series to request", "components.TvCollectionRequestModal.ultraHd": "4K", + "components.TvDetails.Layout.addToCollection": "Add to Collection", + "components.TvDetails.Layout.addToCollectionTooltip": "Add this series to an existing shared collection on your media server. Collection editing permission is required.", + "components.TvDetails.Layout.addToFavorites": "Add to Favorites", + "components.TvDetails.Layout.addToFavoritesTooltip": "Add this series to your media server Favorites.", + "components.TvDetails.Layout.addToWatchlist": "Add to Watchlist", + "components.TvDetails.Layout.addToWatchlistTooltip": "Add this series to your Plex Watchlist.", "components.TvDetails.Layout.airDates": "Air Dates", "components.TvDetails.Layout.country": "Country", "components.TvDetails.Layout.creator": "Creator", @@ -3608,13 +3663,15 @@ "components.TvDetails.Layout.language": "Language", "components.TvDetails.Layout.last": "Last", "components.TvDetails.Layout.mediaAndFormat": "Media & Format", + "components.TvDetails.Layout.mediaServer": "Media Server", + "components.TvDetails.Layout.mediaServerTooltip": "Show or hide season and episode selection and the media server action area without changing your selection.", "components.TvDetails.Layout.minutes": "{minutes} minutes", "components.TvDetails.Layout.network": "Network", "components.TvDetails.Layout.networks": "Networks", "components.TvDetails.Layout.next": "Next", - "components.TvDetails.Layout.noCast": "No cast information available", - "components.TvDetails.Layout.noCrew": "No crew information available", - "components.TvDetails.Layout.noTags": "No subject tags available", + "components.TvDetails.Layout.noCast": "No Cast Information Available", + "components.TvDetails.Layout.noCrew": "No Crew Information Available", + "components.TvDetails.Layout.noTags": "No Subject Tags Available", "components.TvDetails.Layout.notAvailable": "Not Available", "components.TvDetails.Layout.overview": "Overview", "components.TvDetails.Layout.overviewUnavailable": "Overview unavailable", @@ -3638,12 +3695,18 @@ "components.TvDetails.TvCast.fullseriescast": "Full Series Cast", "components.TvDetails.TvCrew.fullseriescrew": "Full Series Crew", "components.TvDetails.addtowatchlist": "Add To Watchlist", + "components.TvDetails.loadError": "Series Details Could Not Be Loaded", + "components.TvDetails.loadErrorHint": "Series details could not be fetched, please try again.", + "components.TvDetails.loading": "Loading Series Details", "components.TvDetails.manageseries": "Manage Series", + "components.TvDetails.pageHeading": "Series Details", "components.TvDetails.recommendations": "Recommendations", "components.TvDetails.removefromwatchlist": "Remove From Watchlist", "components.TvDetails.reportissue": "Report an Issue", + "components.TvDetails.retryMetadataTooltip": "Fetch the series metadata again and check for updated information.", "components.TvDetails.selectToPlay": "No playable episodes are currently available.", "components.TvDetails.similar": "Similar Series", + "components.TvDetails.trailer": "Trailer", "components.TvDetails.watchlistDeleted": "{title} removed from watchlist successfully!", "components.TvDetails.watchlistError": "Something went wrong. Please try again.", "components.TvDetails.watchlistSuccess": "{title} added to watchlist successfully!", @@ -3954,6 +4017,7 @@ "components.UserProfile.seriesrequest": "Series Requests", "components.UserProfile.totalrequests": "Total Requests", "components.UserProfile.unlimited": "Unlimited", + "components.VideoRatings.imdbVotes": "IMDb user score – votes: {formattedCount}", "components.VisualLab.title": "Visual Lab", "discovery.accounts.allowwrites": "Allow SeerrNG to make tracking changes to my {provider} account", "discovery.accounts.authorize": "Authorize SeerrNG on {provider}.", diff --git a/src/pages/visual-lab/testing.tsx b/src/pages/visual-lab/testing.tsx new file mode 100644 index 000000000..ab56f83d0 --- /dev/null +++ b/src/pages/visual-lab/testing.tsx @@ -0,0 +1,6 @@ +import Testing from '@app/components/VisualLab/Testing'; +import type { NextPage } from 'next'; + +const TestingPage: NextPage = () => ; + +export default TestingPage; diff --git a/src/styles/blackoutBackground.test.mjs b/src/styles/blackoutBackground.test.mjs index 860ec8f24..b57ed8ce0 100644 --- a/src/styles/blackoutBackground.test.mjs +++ b/src/styles/blackoutBackground.test.mjs @@ -1,27 +1,81 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import test from 'node:test'; +import postcss from 'postcss'; +import { styleContract } from './cssContract.mjs'; const css = readFileSync(new URL('./globals.css', import.meta.url), 'utf8'); -test('only Blackout disables the corner spotlight and retains its blue gradient', () => { - const root = css.match(/:root\s*\{([^}]+)/)?.[1]; - const blackout = css.match( - /\[data-theme-palette='blackout'\]\s*\{([^}]+)/ - )?.[1]; - assert.match(root, /--theme-page-spotlight-strength: 1;/); - assert.match(blackout, /--theme-page-spotlight-strength: 0;/); - assert.doesNotMatch(blackout, /--theme-page-spotlight-(center|edge):/); - for (const stop of [ - 'light: 40 68 120', - 'main: 26 50 96', - 'deep: 14 28 58', - 'black: 0 0 0', - ]) { - assert.ok(blackout.includes('--theme-page-gradient-' + stop), stop); +const themeContext = readFileSync( + new URL('../context/ThemeContext.tsx', import.meta.url), + 'utf8' +); +const assertBlackoutBackground = (stylesheet) => { + // Blackout is the chrome family; its persisted/runtime palette identifier is seerr. + const palettes = [ + ...themeContext.matchAll(/\{\s*id: '([^']+)',([^{}]*?)\},/g), + ]; + const blackout = palettes.filter(([, , body]) => + body.includes("chrome: 'blackout'") + ); + assert.deepEqual( + blackout.map(([, id]) => id), + ['seerr'] + ); + assert.match( + themeContext, + /document\.documentElement\.dataset\.themePalette = themeTokens\.activePaletteId/ + ); + const palette = "[data-theme-palette='seerr']"; + const contract = styleContract(stylesheet); + assert.equal( + contract.declaration(':root', '--theme-page-spotlight-strength'), + '1' + ); + assert.equal( + contract.declaration(palette, '--theme-page-spotlight-strength'), + '0' + ); + for (const property of ['center', 'edge']) + assert.equal( + contract.declaration(palette, '--theme-page-spotlight-' + property), + undefined + ); + for (const [stop, value] of Object.entries({ + light: '0 0 0', + main: '40 68 120', + deep: '14 28 58', + black: '0 0 0', + })) { + assert.equal( + contract.declaration(palette, '--theme-page-gradient-' + stop), + value + ); } assert.equal( - (css.match(/--theme-page-spotlight-strength: 0;/g) || []).length, - 1 + contract.declaration(palette, '--theme-page-gradient-main-stop'), + '50%' + ); + const disabledSpotlights = []; + postcss + .parse(stylesheet) + .walkDecls('--theme-page-spotlight-strength', (decl) => { + if (decl.value === '0') disabledSpotlights.push(...decl.parent.selectors); + }); + assert.deepEqual(disabledSpotlights, [palette]); +}; + +test('only the runtime Blackout palette disables the spotlight and retains its blue gradient', () => { + assertBlackoutBackground(css); +}); + +test('Blackout check rejects a spotlight override leaked into another palette', () => { + assert.throws( + () => + assertBlackoutBackground( + css + + "\n[data-theme-palette='classic'] { --theme-page-spotlight-strength: 0; }" + ), + assert.AssertionError ); }); test('every shared background spotlight honors the palette strength', () => { diff --git a/src/styles/buttonGeometry.test.mjs b/src/styles/buttonGeometry.test.mjs index fe1483ba2..081e86058 100644 --- a/src/styles/buttonGeometry.test.mjs +++ b/src/styles/buttonGeometry.test.mjs @@ -1,11 +1,76 @@ import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; +import { readFileSync, readdirSync } from 'node:fs'; import test from 'node:test'; +import postcss from 'postcss'; +import ts from 'typescript'; +import { verifySemanticHeadings } from './headingSemanticVerifier.mjs'; +import { auditTailwindClassExpressions } from './tailwindClassVerifier.mjs'; const css = readFileSync(new URL('./globals.css', import.meta.url), 'utf8'); const requestRule = css.match(/\.format-request-control\s*\{([^}]+)\}/)?.[1]; const component = (path) => readFileSync(new URL(`../components/${path}`, import.meta.url), 'utf8'); +const server = (path) => + readFileSync(new URL(`../../server/${path}`, import.meta.url), 'utf8'); +const rule = (selector) => { + const start = css.indexOf(`\n ${selector} {`); + const combinedStart = css.indexOf(`\n ${selector},`); + const matchStart = start >= 0 ? start : combinedStart; + if (matchStart < 0) { + return ''; + } + const open = css.indexOf('{', matchStart); + return css.slice(open + 1, css.indexOf('}', open)); +}; +const rules = (selector) => { + const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return [...css.matchAll(new RegExp(`\\n ${escaped}\\s*\\{([^}]*)\\}`, 'g'))] + .map((match) => match[1]) + .join('\n'); +}; +const classNameTokens = (source) => { + const directClassNames = [ + ...source.matchAll(/className\s*=\s*(?:"([^"]*)"|\{`([\s\S]*?)`\})/g), + ].flatMap((match) => (match[1] ?? match[2] ?? '').split(/\s+/)); + const dynamicClassLiterals = [ + ...source.matchAll(/(['"])([^'"\r\n]*)\1/g), + ].flatMap((match) => match[2].split(/\s+/)); + + return [...directClassNames, ...dynamicClassLiterals] + .map((token) => + token.replace( + /^(?:(?:sm|md|lg|xl|2xl|card|hover|focus|focus-visible|active|disabled|group-hover|group-open|motion-reduce):)+/, + '' + ) + ) + .filter(Boolean); +}; + +const presentationUtility = + /^(?:p[trblxy]?-.+|gap(?:-[xy])?-.+|space-[xy]-.+|text-(?:(?:xs|sm|base|lg|xl|[2-9]xl)$|\[.+|(?:gray|red|amber|yellow|green|emerald|blue|indigo|violet|purple|pink|fuchsia|rose|cyan|teal|black|white)(?:-.+)?$)|font-.+|leading-.+|bg-.+|rounded(?:-.+)?$|shadow(?:-.+)?$|backdrop-.+|ring-.+|border(?:$|-(?:[0-9]+|dashed|dotted|solid|none|gray|red|amber|yellow|green|emerald|blue|indigo|violet|purple|pink|fuchsia|rose|cyan|teal|black|white).*))$/; + +test('Request Status presentation-utility matcher covers variants and exact tokens', () => { + const sample = [ + 'text-xs', + 'hover:text-white', + 'bg-gray-700', + 'px-2', + 'gap-x-3', + 'flex', + 'min-w-0', + 'sm:flex-row', + ]; + const normalized = classNameTokens( + sample.map((token) => `className="${token}"`).join('\n') + ); + const violations = normalized.filter((token) => + presentationUtility.test(token) + ); + assert.deepEqual( + [...new Set(violations)], + ['text-xs', 'text-white', 'bg-gray-700', 'px-2', 'gap-x-3'] + ); +}); test('segmented Request uses the shared action height, not a fixed size', () => { assert.ok(requestRule); @@ -19,23 +84,23 @@ test('segmented Request uses the shared action height, not a fixed size', () => }); test('Request preserves its typography, color and border styling', () => { - for (const utility of [ - 'text-xs', - 'font-medium', - 'text-green-200', - 'border-green-500/80', - 'bg-green-950/35', - 'rounded-md', - 'items-stretch', - ]) { - assert.ok(requestRule?.includes(utility)); - } + assert.match(requestRule, /font-size: var\(--card-table-font-size\)/); + assert.match(requestRule, /font-weight: 500/); + assert.match(requestRule, /align-items: stretch/); + assert.match(requestRule, /border-radius: var\(--control-corner-radius\)/); + for (const property of ['color', 'background-color', 'border']) + assert.match( + requestRule, + new RegExp(`(?:^|\\n)\\s*${property}:\\s*[^;]+;`) + ); + assert.doesNotMatch(requestRule, /@apply/); }); test('action buttons fit a 16px detail row without shrinking their text', () => { assert.match(css, /--action-control-height: 1rem;/); - assert.match(css, /--action-control-padding-x: 7px;/); - assert.match(css, /--compact-button-padding-x: 7px;/); + assert.match(css, /--button-padding-x: 5px;/); + assert.match(css, /--action-control-padding-x: var\(--button-padding-x\);/); + assert.match(css, /--compact-button-padding-x: var\(--button-padding-x\);/); assert.match( css, /padding-inline: var\(--action-control-padding-x\) !important/ @@ -51,25 +116,25 @@ test('action buttons fit a 16px detail row without shrinking their text', () => assert.match(css, /\.button-sm\s*\{[^}]*text-xs/s); }); -test('form input and dropdown control heights remain 20px', () => { +test('editable inputs retain 20px while dropdown buttons share 16px action sizing', () => { assert.match(css, /--compact-control-height: 1\.25rem;/); assert.match( css, - /\.compact-control:is\(button, a\):not\(\[aria-haspopup\]\):not\(\[role='combobox'\]\)/ + /\.compact-control:is\(button, a\)\s*\{[^}]*height: var\(--action-control-height\)/ ); assert.match( css, - /\.app-button\[aria-haspopup\]:not\(\.playback-dropdown-trigger\),[\s\S]*?height: var\(--compact-control-height\) !important;/ + /\.app-button\[aria-haspopup\]:not\(\.playback-dropdown-trigger\),\s*\.app-button\[role='combobox'\]\s*\{[^}]*height: var\(--action-control-height\) !important;/ ); }); test('poster badges keep the shared compact poster geometry', () => { assert.match(css, /--poster-control-height: 1rem;/); - assert.match(css, /--poster-control-padding-x: 4px;/); + assert.match(css, /--poster-control-padding-x: var\(--button-padding-x\);/); assert.match(css, /--poster-control-gap: 0\.125rem;/); assert.match( css, - /\.poster-control\s*\{[^}]*rounded-md[^}]*height: var\(--action-control-height\);[^}]*padding-inline: var\(--action-control-padding-x\);[^}]*column-gap: var\(--poster-control-gap\);/s + /\.poster-control\s*\{[^}]*border-radius: var\(--control-corner-radius\);[^}]*height: var\(--action-control-height\);[^}]*padding-inline: var\(--button-padding-x\);[^}]*column-gap: var\(--poster-control-gap\);/s ); assert.match( css, @@ -131,18 +196,17 @@ test('poster availability and watched badges share a translucent poster surface' } }); -test('request-card availability uses the standard control corner radius', () => { +test('poster badge roles inherit the shared poster-control geometry', () => { + const posterControl = rule('.poster-control'); assert.match( - css, - /\.app-control-standard-radius\s*\{[^}]*border-radius: var\(--control-corner-radius\);/s - ); - const requests = component('Requests/index.tsx'); - assert.match(requests, /app-control-standard-radius/); - assert.doesNotMatch( - requests.match(/className=\{`app-control-standard-radius[^`]+`\}/)?.[0] ?? - '', - /rounded-full/ + posterControl, + /border-radius:|rounded-md/, + 'the shared poster control owns its corner radius' ); + assert.match(posterControl, /padding-inline: var\(--button-padding-x\)/); + assert.match(posterControl, /column-gap: var\(--poster-control-gap\)/); + assert.match(rule('.media-type-badge-width'), /max-width: 100%/); + assert.doesNotMatch(css, /\.poster-media-type-control\b/); }); test('Blocklist source and Issues status badges use the standard radius', () => { @@ -154,17 +218,6 @@ test('Blocklist source and Issues status badges use the standard radius', () => }); test('page buttons and filters retain their approved opacity levels', () => { - const rule = (selector) => { - const start = css.indexOf(`\n ${selector} {`); - const combinedStart = css.indexOf(`\n ${selector},`); - const matchStart = start >= 0 ? start : combinedStart; - if (matchStart < 0) { - return ''; - } - const open = css.indexOf('{', matchStart); - return css.slice(open + 1, css.indexOf('}', open)); - }; - for (const selector of [ '.app-button-manage', '.app-button-bulk-request', @@ -176,36 +229,2110 @@ test('page buttons and filters retain their approved opacity levels', () => { assert.match(rule('.app-button-playback'), /\/ 0\.7\)/); + assert.match(rule('.app-filter-button-idle'), /\/ 0\.55\)/); + assert.match(rule('.app-filter-button-idle:hover'), /\/ 0\.55\)/); + assert.match(rule('.app-filter-button-idle:active'), /\/ 0\.7\)/); + + assert.match(rule('.discover-filter-control'), /\/ 0\.35\)/); + assert.match(rule('.discover-filter-control:hover'), /\/ 0\.55\)/); + assert.match(rule('.discover-filter-control:active'), /\/ 0\.7\)/); + + assert.match( + rule('.app-filter-button-active'), + /background-color: rgb\(var\(--color-indigo-950\) \/ 0\.55\)/ + ); + assert.match( + rule('.watched-status-badge'), + /var\(--poster-control-background-opacity\)/ + ); +}); + +test('icon-only modifiers inherit height without duplicating the standard button owner', () => { + assert.doesNotMatch( + rule('.app-button.app-button-icon-only'), + /(?:^|\n)\s*(?:height|min-height|max-height):/ + ); + assert.match( + rule('.app-button.button-md'), + /height: var\(--action-control-height\)/ + ); + assert.match( + rule('.app-button.button-md'), + /min-height: var\(--action-control-height\)/ + ); + assert.match( + rule('.app-button.button-md'), + /max-height: var\(--action-control-height\)/ + ); + const lab = readFileSync( + new URL('./visual-lab.css', import.meta.url), + 'utf8' + ); + const labIcon = lab.match( + /\.app-button\.app-button-icon-only\s*\{([^}]+)\}/ + )?.[1]; + assert.ok(labIcon); + assert.doesNotMatch(labIcon, /(?:^|\n)\s*(?:height|min-height|max-height):/); +}); + +test('button contents do not create padding exceptions', () => { + assert.match( + css, + /\.app-button\.app-button-icon-only\s*\{[^}]*padding-inline: var\(--button-padding-x\) !important;/s + ); + assert.match( + css, + /\.poster-control-icon,[^}]*padding-inline: var\(--button-padding-x\) !important;/s + ); + assert.match( + css, + /\.app-filter-button\s*\{[^}]*padding-inline: var\(--button-padding-x\) !important;/s + ); + const mediaFilterOption = component('Discover/MediaFilterOption.tsx'); + assert.doesNotMatch(mediaFilterOption, /!(?:gap|p)-|\bpx-\d/); + assert.match(mediaFilterOption, /app-filter-pin-segment/); + assert.match( + rule('.app-filter-segment-focus'), + /padding-inline: var\(--button-padding-x\) !important/ + ); +}); + +test('Request Status keeps each ordered pinnable heading above its filters', () => { + const requests = component('Requests/index.tsx'); + assert.match(requests, /PinnedFilterSectionGroup/); + const task = requests.indexOf("section: 'taskFilters'"); + const media = requests.indexOf("section: 'mediaFilters'"); + const filters = requests.indexOf("section: 'filters'"); + const sort = requests.indexOf("section: 'sortBy'"); + assert.ok(task >= 0 && task < media && media < filters && filters < sort); + const pinnedSections = component('Discover/PinnedFilterSection.tsx'); + assert.match( + pinnedSections, + /media-detail-disclosure-row[\s\S]*DetailDisclosureButton[\s\S]*app-pinned-filter-panel/ + ); + assert.match( + css, + /\.app-pinned-filter-section\s*\{[^}]*margin-bottom: 20px;/s + ); + assert.match(css, /\.discover-filter-control\s*\{[^}]*align-self: center;/s); +}); + +test('filter labels use the shared icon gap and pin artwork has balanced padding', () => { + assert.match( + rule('.discover-filter-control-label'), + /column-gap: var\(--button-content-gap\)/ + ); + assert.match( + rule('.detail-disclosure-pin-icon'), + /transform:\s*rotate\(45deg\)/ + ); + assert.match( + css, + /\.detail-disclosure-button\s*\{[^}]*column-gap: var\(--button-content-gap\)/s + ); + assert.match( + rule('.app-filter-button'), + /column-gap: var\(--button-content-gap\)/ + ); +}); + +test('Request Status filter rows and pagination use shared geometry', () => { + const requests = component('Requests/index.tsx'); + const pagination = component('Common/PaginationFooter/index.tsx'); + assert.equal(requests.match(/className="app-filter-row"/g)?.length, 4); + assert.match(rule('.app-filter-row'), /gap: 5px/); + const taskFilters = requests.slice( + requests.indexOf("section: 'taskFilters'"), + requests.indexOf("section: 'mediaFilters'") + ); + assert.ok( + taskFilters.indexOf(' + taskFilters.indexOf('taskFilterOptions.map'), + 'User selection follows the task-filter buttons' + ); + assert.doesNotMatch( + taskFilters, + /\b(?:ml-auto|ms-auto|justify-end|justify-between)\b/, + 'User selection uses the normal filter gap, not right justification' + ); + assert.doesNotMatch(pagination, /\bh-8\b|\bpx-1\.5\b|\bpy-1\b|